mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-03 10:31:14 +03:00
Compare commits
7 Commits
32ca3c5798
...
0377035b21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0377035b21 | ||
|
|
ae60197cb0 | ||
|
|
407817a3b1 | ||
|
|
08be019bed | ||
|
|
2dd05ca984 | ||
|
|
6fdd65ddb5 | ||
|
|
4b6fa49449 |
@ -143,6 +143,11 @@ If your ONVIF camera does not require authentication credentials, you may still
|
||||
|
||||
:::
|
||||
|
||||
If a camera connects but fails to authenticate, two optional fields can help:
|
||||
|
||||
- `tls_insecure`: Skips TLS certificate verification and sends the ONVIF password as plaintext (`PasswordText`) instead of a hashed digest (`PasswordDigest`). Some cameras reject the digest token and only accept plaintext. This weakens connection security, so only enable it on a trusted local network.
|
||||
- `ignore_time_mismatch`: ONVIF authentication tokens include a timestamp, and a camera will reject the token if its clock differs too much from Frigate's. Enabling this makes Frigate compensate for the time offset so authentication can still succeed. Running NTP on both the camera and the Frigate host is the recommended fix; only use this in a "safe" environment, as it slightly weakens token validation.
|
||||
|
||||
If your camera has multiple ONVIF profiles, you can specify which one to use for PTZ control with the `profile` option, matched by token or name. When not set, Frigate selects the first profile with a valid PTZ configuration. Check the Frigate debug logs (`frigate.ptz.onvif: debug`) to see available profile names and tokens for your camera.
|
||||
|
||||
An ONVIF-capable camera that supports relative movement within the field of view (FOV) can also be configured to automatically track moving objects and keep them in the center of the frame. For autotracking setup, see the [autotracking](autotracking.md) docs.
|
||||
|
||||
@ -153,7 +153,7 @@ Clicking a preview clip seeks the recording player to that timestamp so you can
|
||||
|
||||
Motion Search lets you scan recorded footage for changes inside a region of interest you draw on the camera. Unlike Motion Previews, which surfaces what Frigate's motion detector flagged in real time, Motion Search re-analyzes the saved recordings, so it can find changes that were missed (for example, an object that appeared while motion detection was paused by `lightning_threshold`, or in a region that is normally motion-masked).
|
||||
|
||||
To start a search, click the kebab menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
|
||||
To start a search, open the Actions menu in History or click the kebab menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
|
||||
|
||||
1. Pick the camera and time range to scan.
|
||||
2. Draw a polygon on the camera frame to define the region of interest.
|
||||
@ -170,3 +170,21 @@ To start a search, click the kebab menu on a camera in the <NavPath path="Review
|
||||
Once running, Frigate scans the recording segments that overlap the time range and reports timestamps where changes were detected inside the polygon, along with the percentage of the ROI that changed. Clicking a result seeks the player to that moment so you can review what happened.
|
||||
|
||||
The status panel shows live progress and metrics such as how many segments were scanned, how many were skipped because no motion was recorded for that segment (using the stored motion heatmap), how many frames were decoded, and the total wall-clock time. Segments with no recorded motion in the selected ROI are skipped automatically, which is what makes searching long time ranges practical.
|
||||
|
||||
#### Common use cases
|
||||
|
||||
Frigate's main use case is to record and surface tracked objects, so Motion Search is most useful for the cases where object detection produced nothing — there is no object to find in Explore, but you suspect something happened.
|
||||
|
||||
- **Locating an unattributed change.** You know something appeared, disappeared, or moved in a window of footage — a package now gone, a gate left open — but no detection points to it. A search returns the candidate timestamps instead of scrubbing the timeline by hand.
|
||||
- **An object that was never detected.** Something Frigate doesn't have a model label for, an object too small or distant to be detected, or movement in a region where detection isn't running. The activity left no tracked object but did change the pixels, so a search can still find it.
|
||||
- **Activity while detection was effectively paused.** Changes that occurred while object detection was disabled, motion was suppressed by `skip_motion_threshold`, or inside an area covered by a motion mask, won't appear as review items or tracked objects but can be recovered by searching the recordings directly.
|
||||
|
||||
#### Expected performance
|
||||
|
||||
Motion Search analyzes the saved recordings on demand rather than reading a pre-built index, so a search over a long range takes longer than browsing Motion Previews. Cost scales mainly with how much footage has to be examined: segments with no recorded motion in your ROI are skipped using the stored motion heatmap (shown as "segments skipped" in the status panel), so a quiet range finishes quickly while a busy one takes longer.
|
||||
|
||||
To increase the speed of searches:
|
||||
|
||||
- Draw a tight ROI. Because **Minimum Change Area** is measured as a percentage of the region you draw, a tight ROI around where you expect the change makes the object fill a larger share of the area, so it clears the threshold more easily. A loose ROI makes the same object a small fraction of the region, so it can fall below the threshold and be missed — forcing you to lower Minimum Change Area, which lets in more noise.
|
||||
- Keep Frame Skip high. A higher value samples fewer frames and speeds up the search considerably, while still landing within a few seconds of when the motion or object appeared — close enough to seek to in the recording. Only lower it when you need to pinpoint the exact frame something appears or disappears.
|
||||
- Use Parallel mode to shorten wall-clock time on multi-core systems, at the cost of higher CPU usage while it runs.
|
||||
|
||||
@ -529,6 +529,68 @@ def _extract_fps(r_frame_rate: str) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _build_digest_transport(username: str, password: str) -> AsyncTransport:
|
||||
"""Build a zeep transport backed by an httpx client using HTTP digest auth."""
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
return AsyncTransport(client=client)
|
||||
|
||||
|
||||
async def _connect_onvif_camera(
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
wsdl_base: str | None,
|
||||
auth_type: str,
|
||||
) -> ONVIFCamera:
|
||||
"""Connect to an ONVIF device, trying both WS-Security password encodings.
|
||||
|
||||
Cameras disagree on whether the WS-Security UsernameToken should carry a
|
||||
hashed PasswordDigest or a plaintext PasswordText. The wizard can't know
|
||||
which a given camera expects, so we try PasswordDigest first (the common
|
||||
case) and fall back to PasswordText when the device rejects the token. This
|
||||
is independent of auth_type, which controls HTTP transport-level auth.
|
||||
"""
|
||||
first_error: Fault | None = None
|
||||
|
||||
# encrypt=True -> PasswordDigest, encrypt=False -> PasswordText
|
||||
for encrypt in (True, False):
|
||||
onvif_camera = ONVIFCamera(
|
||||
host,
|
||||
port,
|
||||
username or "",
|
||||
password or "",
|
||||
wsdl_dir=wsdl_base,
|
||||
encrypt=encrypt,
|
||||
)
|
||||
|
||||
try:
|
||||
await onvif_camera.update_xaddrs()
|
||||
except Fault as e:
|
||||
# A SOAP fault here is how a camera signals the wrong password
|
||||
# encoding, so retry with the other encoding before giving up.
|
||||
logger.debug(
|
||||
"ONVIF connect with %s rejected, trying alternate encoding",
|
||||
"PasswordDigest" if encrypt else "PasswordText",
|
||||
)
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
continue
|
||||
|
||||
if auth_type == "digest" and username and password:
|
||||
transport = _build_digest_transport(username, password)
|
||||
for service in ("devicemgmt", "media", "ptz"):
|
||||
if hasattr(onvif_camera, service):
|
||||
getattr(onvif_camera, service).zeep_client.transport = transport
|
||||
logger.debug("Configured digest authentication")
|
||||
|
||||
return onvif_camera
|
||||
|
||||
# Both encodings failed authentication; surface the original fault.
|
||||
raise first_error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/onvif/probe",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
@ -605,34 +667,10 @@ async def onvif_probe(
|
||||
except Exception:
|
||||
wsdl_base = None
|
||||
|
||||
onvif_camera = ONVIFCamera(
|
||||
host, port, username or "", password or "", wsdl_dir=wsdl_base
|
||||
onvif_camera = await _connect_onvif_camera(
|
||||
host, port, username, password, wsdl_base, auth_type
|
||||
)
|
||||
|
||||
# Configure digest authentication if requested
|
||||
if auth_type == "digest" and username and password:
|
||||
# Create httpx client with digest auth
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
|
||||
# Replace the transport in the zeep client
|
||||
transport = AsyncTransport(client=client)
|
||||
|
||||
# Update the xaddr before setting transport
|
||||
await onvif_camera.update_xaddrs()
|
||||
|
||||
# Replace transport in all services
|
||||
if hasattr(onvif_camera, "devicemgmt"):
|
||||
onvif_camera.devicemgmt.zeep_client.transport = transport
|
||||
if hasattr(onvif_camera, "media"):
|
||||
onvif_camera.media.zeep_client.transport = transport
|
||||
if hasattr(onvif_camera, "ptz"):
|
||||
onvif_camera.ptz.zeep_client.transport = transport
|
||||
|
||||
logger.debug("Configured digest authentication")
|
||||
else:
|
||||
await onvif_camera.update_xaddrs()
|
||||
|
||||
# Get device information
|
||||
device_info = {
|
||||
"manufacturer": "Unknown",
|
||||
@ -644,10 +682,9 @@ async def onvif_probe(
|
||||
|
||||
# Update transport for device service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
device_service.zeep_client.transport = transport
|
||||
device_service.zeep_client.transport = _build_digest_transport(
|
||||
username, password
|
||||
)
|
||||
|
||||
device_info_resp = await device_service.GetDeviceInformation()
|
||||
manufacturer = getattr(device_info_resp, "Manufacturer", None) or (
|
||||
@ -685,10 +722,9 @@ async def onvif_probe(
|
||||
|
||||
# Update transport for media service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
media_service.zeep_client.transport = transport
|
||||
media_service.zeep_client.transport = _build_digest_transport(
|
||||
username, password
|
||||
)
|
||||
|
||||
profiles = await media_service.GetProfiles()
|
||||
profiles_count = len(profiles) if profiles else 0
|
||||
@ -720,10 +756,9 @@ async def onvif_probe(
|
||||
|
||||
# Update transport for PTZ service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
ptz_service.zeep_client.transport = transport
|
||||
ptz_service.zeep_client.transport = _build_digest_transport(
|
||||
username, password
|
||||
)
|
||||
|
||||
# Check if PTZ service is available
|
||||
try:
|
||||
@ -876,10 +911,9 @@ async def onvif_probe(
|
||||
|
||||
# Update transport for media service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
media_service.zeep_client.transport = transport
|
||||
media_service.zeep_client.transport = _build_digest_transport(
|
||||
username, password
|
||||
)
|
||||
|
||||
if profiles_count and media_service:
|
||||
for p in profiles or []:
|
||||
|
||||
@ -280,7 +280,7 @@ async def create_face(request: Request, name: str):
|
||||
success response with details about the registration, or an error if face recognition
|
||||
is not enabled or the image cannot be processed.""",
|
||||
)
|
||||
async def register_face(request: Request, name: str, file: UploadFile):
|
||||
def register_face(request: Request, name: str, file: UploadFile):
|
||||
if not request.app.frigate_config.face_recognition.enabled:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
@ -288,7 +288,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
|
||||
)
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
result = None if context is None else context.register_face(name, await file.read())
|
||||
result = None if context is None else context.register_face(name, file.file.read())
|
||||
|
||||
if not isinstance(result, dict):
|
||||
return JSONResponse(
|
||||
@ -313,7 +313,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
|
||||
registered faces in the system. Returns the recognized face name and confidence score,
|
||||
or an error if face recognition is not enabled or the image cannot be processed.""",
|
||||
)
|
||||
async def recognize_face(request: Request, file: UploadFile):
|
||||
def recognize_face(request: Request, file: UploadFile):
|
||||
if not request.app.frigate_config.face_recognition.enabled:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
@ -321,7 +321,7 @@ async def recognize_face(request: Request, file: UploadFile):
|
||||
)
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
result = context.recognize_face(await file.read())
|
||||
result = context.recognize_face(file.file.read())
|
||||
|
||||
if not isinstance(result, dict):
|
||||
return JSONResponse(
|
||||
|
||||
@ -42,9 +42,9 @@ class MotionSearchRequest(BaseModel):
|
||||
description="Minimum change area as a percentage of the ROI",
|
||||
)
|
||||
frame_skip: int = Field(
|
||||
default=5,
|
||||
default=30,
|
||||
ge=1,
|
||||
le=30,
|
||||
le=120,
|
||||
description="Process every Nth frame (1=all frames, 5=every 5th frame)",
|
||||
)
|
||||
parallel: bool = Field(
|
||||
|
||||
@ -345,13 +345,21 @@ class FrigateApp:
|
||||
)
|
||||
self.dispatcher.profile_manager = self.profile_manager
|
||||
|
||||
def restore_active_profile(self) -> None:
|
||||
"""Re-activate the persisted profile after subscribers are connected.
|
||||
|
||||
ZMQ PUB/SUB drops messages with no subscribers, so activation must
|
||||
run after every config_updater subscriber is up.
|
||||
"""
|
||||
if self.profile_manager is None:
|
||||
return
|
||||
|
||||
persisted = ProfileManager.load_persisted_profile()
|
||||
if persisted and any(
|
||||
persisted in cam.profiles for cam in self.config.cameras.values()
|
||||
):
|
||||
logger.info("Restoring persisted profile '%s'", persisted)
|
||||
# don't clear runtime overrides here, restore_runtime_state() later
|
||||
# in startup replays it on top of the activated profile
|
||||
# runtime overrides are layered on top via restore_runtime_state()
|
||||
self.profile_manager.activate_profile(
|
||||
persisted, clear_runtime_overrides=False
|
||||
)
|
||||
@ -619,6 +627,7 @@ class FrigateApp:
|
||||
self.start_watchdog()
|
||||
|
||||
# restore persisted runtime overrides on top of config
|
||||
self.restore_active_profile()
|
||||
self.dispatcher.restore_runtime_state()
|
||||
|
||||
self.init_auth()
|
||||
|
||||
@ -94,9 +94,21 @@ class AudioProcessor(FrigateProcess):
|
||||
self.camera_metrics = camera_metrics
|
||||
self.config = config
|
||||
|
||||
def __stop_audio_thread(self, camera: str) -> None:
|
||||
thread = self.audio_threads.pop(camera, None)
|
||||
if thread is None:
|
||||
return
|
||||
|
||||
thread.stop()
|
||||
thread.join(10)
|
||||
if thread.is_alive():
|
||||
self.logger.warning(f"Audio maintainer thread for {camera} is still alive")
|
||||
else:
|
||||
self.logger.info(f"Audio maintainer stopped for {camera}")
|
||||
|
||||
def run(self) -> None:
|
||||
self.pre_run_setup(self.config.logger)
|
||||
audio_threads: dict[str, AudioEventMaintainer] = {}
|
||||
self.audio_threads: dict[str, AudioEventMaintainer] = {}
|
||||
|
||||
threading.current_thread().name = "process:audio_manager"
|
||||
|
||||
@ -120,12 +132,13 @@ class AudioProcessor(FrigateProcess):
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.audio,
|
||||
CameraConfigUpdateEnum.ffmpeg,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
],
|
||||
)
|
||||
|
||||
def spawn_if_needed(camera: CameraConfig) -> None:
|
||||
name = camera.name
|
||||
if name is None or name in audio_threads:
|
||||
if name is None or name in self.audio_threads:
|
||||
return
|
||||
if not camera.enabled or not camera.audio.enabled:
|
||||
return
|
||||
@ -139,7 +152,7 @@ class AudioProcessor(FrigateProcess):
|
||||
self.transcription_model_runner,
|
||||
self.stop_event, # type: ignore[arg-type]
|
||||
)
|
||||
audio_threads[name] = thread
|
||||
self.audio_threads[name] = thread
|
||||
thread.start()
|
||||
self.logger.info(f"Audio maintainer started for {name}")
|
||||
|
||||
@ -148,21 +161,31 @@ class AudioProcessor(FrigateProcess):
|
||||
|
||||
self.logger.info(f"Audio processor started (pid: {self.pid})")
|
||||
|
||||
# poll for newly added cameras or cameras flipped to audio.enabled at runtime
|
||||
# poll for newly added/removed cameras or cameras flipped to
|
||||
# audio.enabled at runtime
|
||||
while not self.stop_event.wait(timeout=1.0):
|
||||
config_subscriber.check_for_updates()
|
||||
updated_topics = config_subscriber.check_for_updates()
|
||||
|
||||
# stop maintainers for removed cameras so their ffmpeg process is
|
||||
# torn down and they stop touching camera_metrics (which the camera
|
||||
# maintainer has already popped for the removed camera)
|
||||
for removed_camera in updated_topics.get(
|
||||
CameraConfigUpdateEnum.remove.name, []
|
||||
):
|
||||
self.__stop_audio_thread(removed_camera)
|
||||
|
||||
for camera in self.config.cameras.values():
|
||||
spawn_if_needed(camera)
|
||||
|
||||
config_subscriber.stop()
|
||||
|
||||
for thread in audio_threads.values():
|
||||
for thread in self.audio_threads.values():
|
||||
thread.join(1)
|
||||
if thread.is_alive():
|
||||
self.logger.info(f"Waiting for thread {thread.name:s} to exit")
|
||||
thread.join(10)
|
||||
|
||||
for thread in audio_threads.values():
|
||||
for thread in self.audio_threads.values():
|
||||
if thread.is_alive():
|
||||
self.logger.warning(f"Thread {thread.name} is still alive")
|
||||
|
||||
@ -184,6 +207,9 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.camera_config = camera
|
||||
self.camera_metrics = camera_metrics
|
||||
self.stop_event = stop_event
|
||||
# per-camera stop signal so a single maintainer can be torn down at
|
||||
# runtime (e.g. on camera removal) without stopping the whole process
|
||||
self.camera_stop_event = threading.Event()
|
||||
self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads)
|
||||
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
|
||||
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
|
||||
@ -233,7 +259,11 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.was_audio_enabled = camera.audio.enabled
|
||||
|
||||
def detect_audio(self, audio: np.ndarray) -> None:
|
||||
if not self.camera_config.audio.enabled or self.stop_event.is_set():
|
||||
if (
|
||||
not self.camera_config.audio.enabled
|
||||
or self.stop_event.is_set()
|
||||
or self.camera_stop_event.is_set()
|
||||
):
|
||||
return
|
||||
|
||||
audio_as_float: np.ndarray = audio.astype(np.float32)
|
||||
@ -352,11 +382,15 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.logger.error(f"Error reading audio data from ffmpeg process: {e}")
|
||||
log_and_restart()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Signal this maintainer to exit its run loop and clean up."""
|
||||
self.camera_stop_event.set()
|
||||
|
||||
def run(self) -> None:
|
||||
if self.camera_config.enabled:
|
||||
self.start_or_restart_ffmpeg()
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
while not self.stop_event.is_set() and not self.camera_stop_event.is_set():
|
||||
# check if there is an updated config
|
||||
self.config_subscriber.check_for_updates()
|
||||
|
||||
|
||||
124
frigate/test/test_onvif_probe.py
Normal file
124
frigate/test/test_onvif_probe.py
Normal file
@ -0,0 +1,124 @@
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
from zeep.transports import AsyncTransport
|
||||
|
||||
from frigate.api.camera import _build_digest_transport, _connect_onvif_camera
|
||||
|
||||
|
||||
def _make_camera(update_side_effect=None):
|
||||
"""Build a mock ONVIFCamera whose update_xaddrs can raise or succeed."""
|
||||
camera = MagicMock()
|
||||
camera.update_xaddrs = AsyncMock(side_effect=update_side_effect)
|
||||
return camera
|
||||
|
||||
|
||||
class TestConnectOnvifCamera(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_password_digest_succeeds_first(self):
|
||||
# Cameras that accept PasswordDigest authenticate on the first attempt
|
||||
# and should never trigger the PasswordText fallback.
|
||||
camera = _make_camera()
|
||||
|
||||
with patch("frigate.api.camera.ONVIFCamera", return_value=camera) as mock_cls:
|
||||
result = await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "basic"
|
||||
)
|
||||
|
||||
self.assertIs(result, camera)
|
||||
mock_cls.assert_called_once()
|
||||
self.assertTrue(mock_cls.call_args.kwargs["encrypt"])
|
||||
|
||||
async def test_falls_back_to_password_text(self):
|
||||
# A PasswordDigest rejection should retry once with PasswordText.
|
||||
camera_digest = _make_camera(update_side_effect=Fault("token rejected"))
|
||||
camera_text = _make_camera()
|
||||
|
||||
with patch(
|
||||
"frigate.api.camera.ONVIFCamera",
|
||||
side_effect=[camera_digest, camera_text],
|
||||
) as mock_cls:
|
||||
result = await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "basic"
|
||||
)
|
||||
|
||||
self.assertIs(result, camera_text)
|
||||
self.assertEqual(mock_cls.call_count, 2)
|
||||
self.assertTrue(mock_cls.call_args_list[0].kwargs["encrypt"])
|
||||
self.assertFalse(mock_cls.call_args_list[1].kwargs["encrypt"])
|
||||
|
||||
async def test_both_encodings_fail_raises_first_fault(self):
|
||||
# When both encodings fault, the original (PasswordDigest) fault is
|
||||
# surfaced so the caller's existing Fault handler reports it.
|
||||
first_fault = Fault("digest rejected")
|
||||
camera_digest = _make_camera(update_side_effect=first_fault)
|
||||
camera_text = _make_camera(update_side_effect=Fault("text rejected"))
|
||||
|
||||
with patch(
|
||||
"frigate.api.camera.ONVIFCamera",
|
||||
side_effect=[camera_digest, camera_text],
|
||||
) as mock_cls:
|
||||
with self.assertRaises(Fault) as ctx:
|
||||
await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "basic"
|
||||
)
|
||||
|
||||
self.assertIs(ctx.exception, first_fault)
|
||||
self.assertEqual(mock_cls.call_count, 2)
|
||||
|
||||
async def test_transport_error_is_not_retried(self):
|
||||
# Connection-level errors (timeout, refused, unreachable) should
|
||||
# propagate immediately without doubling latency on a second encoding.
|
||||
camera = _make_camera(update_side_effect=TransportError("unreachable"))
|
||||
|
||||
with patch("frigate.api.camera.ONVIFCamera", side_effect=[camera]) as mock_cls:
|
||||
with self.assertRaises(TransportError):
|
||||
await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "basic"
|
||||
)
|
||||
|
||||
mock_cls.assert_called_once()
|
||||
|
||||
async def test_digest_auth_replaces_service_transports(self):
|
||||
# auth_type "digest" wires an HTTP digest transport onto each service,
|
||||
# independently of the WS-Security encoding.
|
||||
camera = _make_camera()
|
||||
|
||||
with (
|
||||
patch("frigate.api.camera.ONVIFCamera", return_value=camera),
|
||||
patch(
|
||||
"frigate.api.camera._build_digest_transport",
|
||||
return_value="TRANSPORT",
|
||||
) as mock_transport,
|
||||
):
|
||||
result = await _connect_onvif_camera(
|
||||
"cam.local", 80, "user", "pass", None, "digest"
|
||||
)
|
||||
|
||||
self.assertIs(result, camera)
|
||||
mock_transport.assert_called_once_with("user", "pass")
|
||||
self.assertEqual(camera.devicemgmt.zeep_client.transport, "TRANSPORT")
|
||||
self.assertEqual(camera.media.zeep_client.transport, "TRANSPORT")
|
||||
self.assertEqual(camera.ptz.zeep_client.transport, "TRANSPORT")
|
||||
|
||||
async def test_basic_auth_does_not_replace_transports(self):
|
||||
# Without digest auth, no transport override is built.
|
||||
camera = _make_camera()
|
||||
|
||||
with (
|
||||
patch("frigate.api.camera.ONVIFCamera", return_value=camera),
|
||||
patch("frigate.api.camera._build_digest_transport") as mock_transport,
|
||||
):
|
||||
await _connect_onvif_camera("cam.local", 80, "user", "pass", None, "basic")
|
||||
|
||||
mock_transport.assert_not_called()
|
||||
|
||||
|
||||
class TestBuildDigestTransport(unittest.TestCase):
|
||||
def test_returns_async_transport(self):
|
||||
transport = _build_digest_transport("user", "pass")
|
||||
self.assertIsInstance(transport, AsyncTransport)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -67,7 +67,7 @@
|
||||
"needsReview": "Needs review",
|
||||
"securityConcern": "Security concern",
|
||||
"motionSearch": {
|
||||
"menuItem": "Motion search",
|
||||
"menuItem": "Motion Search",
|
||||
"openMenu": "Camera options"
|
||||
},
|
||||
"motionPreviews": {
|
||||
|
||||
@ -24,7 +24,9 @@
|
||||
"points_one": "{{count}} point",
|
||||
"points_other": "{{count}} points",
|
||||
"undo": "Undo last point",
|
||||
"reset": "Reset polygon"
|
||||
"reset": "Reset polygon",
|
||||
"drawMode": "Draw",
|
||||
"moveMode": "Move"
|
||||
},
|
||||
"motionHeatmapLabel": "Motion Heatmap",
|
||||
"dialog": {
|
||||
|
||||
@ -320,7 +320,7 @@
|
||||
"nameLength": "Camera name must be 64 characters or less",
|
||||
"invalidCharacters": "Camera name contains invalid characters",
|
||||
"nameExists": "Camera name already exists",
|
||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams."
|
||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\" or \"rtsps://\". Manual configuration is required for non-RTSP camera streams."
|
||||
}
|
||||
},
|
||||
"step2": {
|
||||
|
||||
@ -107,7 +107,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
<FormLabel>{t("form.user")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
autoFocus
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
@ -125,7 +125,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
<FormLabel>{t("form.password")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
|
||||
@ -14,8 +14,8 @@ const BlurredIconButton = forwardRef<HTMLDivElement, BlurredIconButtonProps>(
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-0 blur-sm transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
|
||||
<div className="relative z-10 cursor-pointer text-white/85 hover:text-white">
|
||||
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-30 blur-md transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
|
||||
<div className="relative z-10 cursor-pointer text-white/85 drop-shadow-[0_1px_1px_rgba(0,0,0,0.9)] hover:text-white">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -257,7 +257,7 @@ export function ExportCard({
|
||||
{editName && (
|
||||
<>
|
||||
<Input
|
||||
className="text-md mt-3"
|
||||
className="mt-3"
|
||||
type="search"
|
||||
placeholder={editName?.original}
|
||||
value={
|
||||
@ -275,7 +275,6 @@ export function ExportCard({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("editExport.saveExport")}
|
||||
size="sm"
|
||||
variant="select"
|
||||
disabled={(editName?.update?.length ?? 0) == 0}
|
||||
onClick={() => submitRename()}
|
||||
|
||||
@ -14,7 +14,7 @@ type SettingsGroupCardProps = {
|
||||
export function SettingsGroupCard({ title, children }: SettingsGroupCardProps) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4">
|
||||
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
|
||||
<div className="border-b border-border/60 pb-4 font-semibold text-primary-variant">
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@ -48,7 +48,7 @@ export default function ChatSettings({
|
||||
<div className="my-3 space-y-5 py-3 md:mt-0 md:py-0">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">{t("settings.show_stats.title")}</div>
|
||||
<div>{t("settings.show_stats.title")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("settings.show_stats.desc")}
|
||||
</div>
|
||||
@ -77,7 +77,7 @@ export default function ChatSettings({
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-scroll" className="text-md cursor-pointer">
|
||||
<Label htmlFor="auto-scroll" className="cursor-pointer">
|
||||
{t("settings.auto_scroll.title")}
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
||||
@ -485,7 +485,7 @@ export default function ClassificationModelEditDialog({
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="h-8"
|
||||
placeholder={t(
|
||||
"wizard.step1.classPlaceholder",
|
||||
)}
|
||||
|
||||
@ -214,7 +214,7 @@ export default function Step1NameAndDefine({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="h-8"
|
||||
placeholder={t("wizard.step1.namePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
@ -457,7 +457,7 @@ export default function Step1NameAndDefine({
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="h-8"
|
||||
placeholder={t("wizard.step1.classPlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
@ -489,7 +489,7 @@ export default function Step1NameAndDefine({
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -458,7 +458,7 @@ export default function Step2StateArea({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
@ -540,7 +540,7 @@ export default function Step3ChooseExamples({
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={doRefresh}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
>
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
@ -693,7 +693,7 @@ export default function Step3ChooseExamples({
|
||||
)}
|
||||
|
||||
{!isTraining && (
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={handleBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@ -491,7 +490,6 @@ export default function NotificationsSettingsExtras({
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
|
||||
<div className={cn("w-full max-w-5xl space-y-6")}>
|
||||
{isAdmin && (
|
||||
@ -521,7 +519,7 @@ export default function NotificationsSettingsExtras({
|
||||
<FormControl>
|
||||
<Input
|
||||
id="notification-email"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
|
||||
placeholder={t(
|
||||
"notification.email.placeholder",
|
||||
)}
|
||||
@ -788,7 +786,7 @@ export function CameraNotificationSwitch({
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<CameraNameLabel
|
||||
className="text-md cursor-pointer text-primary smart-capitalize"
|
||||
className="cursor-pointer text-primary smart-capitalize"
|
||||
htmlFor="camera"
|
||||
camera={camera}
|
||||
/>
|
||||
|
||||
@ -32,7 +32,7 @@ import { ProfileOverridesBadge } from "./ProfileOverridesBadge";
|
||||
import { useSectionSchema } from "@/hooks/use-config-schema";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import get from "lodash/get";
|
||||
@ -1236,7 +1236,7 @@ export function ConfigSection({
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
onClick={() => {
|
||||
onDeleteProfileSection?.();
|
||||
setIsDeleteProfileDialogOpen(false);
|
||||
|
||||
@ -371,7 +371,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
key={group.groupKey}
|
||||
className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4"
|
||||
>
|
||||
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
|
||||
<div className="border-b border-border/60 pb-4 font-semibold text-primary-variant">
|
||||
{group.label}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
|
||||
@ -79,7 +79,7 @@ export function ArrayAsTextWidget(props: WidgetProps) {
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
className={cn(fieldClassName)}
|
||||
value={text}
|
||||
disabled={disabled || readonly}
|
||||
rows={(options.rows as number) || 3}
|
||||
|
||||
@ -124,7 +124,7 @@ export function CameraPathWidget(props: WidgetProps) {
|
||||
<div className={cn("relative", fieldClassName)}>
|
||||
<Input
|
||||
id={id}
|
||||
className={cn("text-md", canToggle ? "pr-10" : undefined)}
|
||||
className={cn(canToggle ? "pr-10" : undefined)}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
disabled={disabled || readonly}
|
||||
|
||||
@ -26,7 +26,7 @@ export function TextWidget(props: WidgetProps) {
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
className={cn(fieldClassName)}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
|
||||
@ -26,7 +26,7 @@ export function TextareaWidget(props: WidgetProps) {
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
className={cn(fieldClassName)}
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || (options.placeholder as string) || ""}
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
@ -847,7 +848,7 @@ export function CameraGroupEdit({
|
||||
<FormLabel>{t("group.name.label")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
placeholder={t("group.name.placeholder")}
|
||||
{...field}
|
||||
/>
|
||||
@ -973,10 +974,9 @@ export function CameraGroupEdit({
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<div className="flex flex-row gap-2 py-5 md:pb-0">
|
||||
<DialogFooter className="py-5 md:pb-0">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
>
|
||||
@ -985,7 +985,6 @@ export function CameraGroupEdit({
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
type="submit"
|
||||
>
|
||||
@ -998,7 +997,7 @@ export function CameraGroupEdit({
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@ -40,7 +40,7 @@ export function LogSettingsButton({
|
||||
<div className={cn("my-3 space-y-3 py-3 md:mt-0 md:py-0")}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">{t("filter")}</div>
|
||||
<div>{t("filter")}</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{t("logSettings.filterBySeverity")}
|
||||
</div>
|
||||
@ -53,7 +53,7 @@ export function LogSettingsButton({
|
||||
<DropdownMenuSeparator />
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">{t("logSettings.loading.title")}</div>
|
||||
<div>{t("logSettings.loading.title")}</div>
|
||||
<div className="mt-2.5 flex flex-col gap-2.5">
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{t("logSettings.loading.desc")}
|
||||
|
||||
@ -56,18 +56,25 @@ export function CameraLineGraph({
|
||||
});
|
||||
}, [t, timeFormat]);
|
||||
|
||||
const updateTimesRef = useRef(updateTimes);
|
||||
useEffect(() => {
|
||||
updateTimesRef.current = updateTimes;
|
||||
}, [updateTimes]);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
return formatUnixTimestampToDateTime(
|
||||
updateTimes[Math.round(val as number)],
|
||||
{
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
},
|
||||
);
|
||||
const times = updateTimesRef.current;
|
||||
const ts = times[Math.round(val as number)];
|
||||
if (isNaN(ts)) {
|
||||
return "";
|
||||
}
|
||||
return formatUnixTimestampToDateTime(ts, {
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
},
|
||||
[config?.ui.timezone, format, locale, updateTimes],
|
||||
[config?.ui.timezone, format, locale],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
@ -211,18 +218,25 @@ export function EventsPerSecondsLineGraph({
|
||||
});
|
||||
}, [t, timeFormat]);
|
||||
|
||||
const updateTimesRef = useRef(updateTimes);
|
||||
useEffect(() => {
|
||||
updateTimesRef.current = updateTimes;
|
||||
}, [updateTimes]);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
return formatUnixTimestampToDateTime(
|
||||
updateTimes[Math.round(val as number) - 1],
|
||||
{
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
},
|
||||
);
|
||||
const times = updateTimesRef.current;
|
||||
const ts = times[Math.round(val as number) - 1];
|
||||
if (isNaN(ts)) {
|
||||
return "";
|
||||
}
|
||||
return formatUnixTimestampToDateTime(ts, {
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
},
|
||||
[config?.ui.timezone, format, locale, updateTimes],
|
||||
[config?.ui.timezone, format, locale],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
|
||||
@ -61,6 +61,11 @@ export function ThresholdBarGraph({
|
||||
});
|
||||
}, [t, timeFormat]);
|
||||
|
||||
const updateTimesRef = useRef(updateTimes);
|
||||
useEffect(() => {
|
||||
updateTimesRef.current = updateTimes;
|
||||
}, [updateTimes]);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(val: unknown) => {
|
||||
const dateIndex = Math.round(val as number);
|
||||
@ -69,16 +74,18 @@ export function ThresholdBarGraph({
|
||||
if (dateIndex < 0) {
|
||||
timeOffset = 5 * Math.abs(dateIndex);
|
||||
}
|
||||
return formatUnixTimestampToDateTime(
|
||||
updateTimes[Math.max(1, dateIndex) - 1] - timeOffset,
|
||||
{
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
},
|
||||
);
|
||||
const times = updateTimesRef.current;
|
||||
const ts = times[Math.max(1, dateIndex) - 1] - timeOffset;
|
||||
if (isNaN(ts)) {
|
||||
return "";
|
||||
}
|
||||
return formatUnixTimestampToDateTime(ts, {
|
||||
timezone: config?.ui.timezone,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
},
|
||||
[config?.ui.timezone, format, locale, updateTimes],
|
||||
[config?.ui.timezone, format, locale],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
|
||||
@ -119,7 +119,7 @@ export default function IconPicker({
|
||||
placeholder={t("iconPicker.search.placeholder", {
|
||||
ns: "components/icons",
|
||||
})}
|
||||
className="text-md mb-3 md:text-sm"
|
||||
className="mb-3 md:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
@ -696,7 +696,7 @@ export default function InputWithTags({
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
className="text-md h-9 pr-32"
|
||||
className="h-9 pr-32"
|
||||
placeholder={t("placeholder.search")}
|
||||
/>
|
||||
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
|
||||
|
||||
@ -112,11 +112,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
</span>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={placeholderName}
|
||||
{...field}
|
||||
/>
|
||||
<Input placeholder={placeholderName} {...field} />
|
||||
</FormControl>
|
||||
{nameDescription && (
|
||||
<FormDescription>{nameDescription}</FormDescription>
|
||||
@ -134,7 +130,6 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
<FormLabel>{idLabel ?? t("label.ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={placeholderId}
|
||||
disabled={idDisabled}
|
||||
{...field}
|
||||
|
||||
@ -69,7 +69,6 @@ export function SaveSearchDialog({
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={searchName}
|
||||
className="text-md"
|
||||
onChange={(e) => setSearchName(e.target.value)}
|
||||
placeholder={t("search.saveSearch.placeholder")}
|
||||
/>
|
||||
@ -88,7 +87,6 @@ export function SaveSearchDialog({
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="select"
|
||||
className="mb-2 md:mb-0"
|
||||
aria-label={t("search.saveSearch.button.save.label")}
|
||||
>
|
||||
{t("button.save", { ns: "common" })}
|
||||
|
||||
@ -77,7 +77,7 @@ export default function TextEntry({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="text-md w-full"
|
||||
className="w-full"
|
||||
placeholder={placeholder}
|
||||
type="text"
|
||||
/>
|
||||
|
||||
@ -276,7 +276,7 @@ export default function LiveContextMenu({
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<div className="flex flex-col items-start gap-1 py-1 pl-2">
|
||||
<div className="text-md text-primary-variant smart-capitalize">
|
||||
<div className="text-primary-variant smart-capitalize">
|
||||
<CameraNameLabel camera={camera} />
|
||||
</div>
|
||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||
|
||||
@ -12,14 +12,21 @@ type ActionsDropdownProps = {
|
||||
onDebugReplayClick?: () => void;
|
||||
onExportClick: () => void;
|
||||
onShareTimestampClick: () => void;
|
||||
onMotionSearchClick?: () => void;
|
||||
};
|
||||
|
||||
export default function ActionsDropdown({
|
||||
onDebugReplayClick,
|
||||
onExportClick,
|
||||
onShareTimestampClick,
|
||||
onMotionSearchClick,
|
||||
}: Readonly<ActionsDropdownProps>) {
|
||||
const { t } = useTranslation(["components/dialog", "views/replay", "common"]);
|
||||
const { t } = useTranslation([
|
||||
"components/dialog",
|
||||
"views/replay",
|
||||
"views/events",
|
||||
"common",
|
||||
]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@ -42,6 +49,11 @@ export default function ActionsDropdown({
|
||||
<DropdownMenuItem onClick={onShareTimestampClick}>
|
||||
{t("recording.shareTimestamp.label", { ns: "components/dialog" })}
|
||||
</DropdownMenuItem>
|
||||
{onMotionSearchClick && (
|
||||
<DropdownMenuItem onClick={onMotionSearchClick}>
|
||||
{t("motionSearch.menuItem", { ns: "views/events" })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDebugReplayClick && (
|
||||
<DropdownMenuItem onClick={onDebugReplayClick}>
|
||||
{t("title", { ns: "views/replay" })}
|
||||
|
||||
@ -213,36 +213,30 @@ export default function CreateRoleDialog({
|
||||
<FormMessage />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -433,36 +433,30 @@ export default function CreateTriggerDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -411,36 +411,30 @@ export default function CreateUserDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -144,7 +144,7 @@ export function CustomTimeSelector({
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={startClock}
|
||||
@ -210,7 +210,7 @@ export function CustomTimeSelector({
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="endTime"
|
||||
type="time"
|
||||
value={endClock}
|
||||
|
||||
@ -113,19 +113,14 @@ export function DebugReplayContent({
|
||||
|
||||
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
|
||||
|
||||
<DialogFooter
|
||||
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-2"}
|
||||
>
|
||||
<DialogFooter className="mt-3 sm:mt-0">
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
variant="select"
|
||||
disabled={isStarting}
|
||||
onClick={() => {
|
||||
|
||||
@ -70,38 +70,31 @@ export default function DeleteRoleDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
variant="destructive"
|
||||
disabled={isLoading}
|
||||
onClick={handleDelete}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("roles.dialog.deleteRole.deleting")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
variant="destructive"
|
||||
disabled={isLoading}
|
||||
onClick={handleDelete}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("roles.dialog.deleteRole.deleting")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -43,36 +43,30 @@ export default function DeleteTriggerDialog({
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
className="flex flex-1 text-white"
|
||||
onClick={onDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("button.delete", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
onClick={onDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("button.delete", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -46,27 +46,21 @@ export default function DeleteUserDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
className="flex flex-1 text-white"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -105,13 +105,15 @@ export default function EditRoleCamerasDialog({
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-5 pt-4"
|
||||
className="space-y-5 pt-2"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
|
||||
<FormDescription className="text-xs text-muted-foreground">
|
||||
{t("roles.dialog.form.cameras.desc")}
|
||||
</FormDescription>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
|
||||
<FormDescription className="text-xs text-muted-foreground">
|
||||
{t("roles.dialog.form.cameras.desc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto">
|
||||
{cameras.map((camera) => (
|
||||
<FormField
|
||||
@ -159,36 +161,30 @@ export default function EditRoleCamerasDialog({
|
||||
<FormMessage />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -287,7 +287,7 @@ export default function ExportDialog({
|
||||
<Content
|
||||
className={
|
||||
isDesktop
|
||||
? "sm:rounded-lg md:rounded-2xl"
|
||||
? "scrollbar-container max-h-[90dvh] overflow-y-auto sm:rounded-lg md:rounded-2xl"
|
||||
: "mx-4 rounded-lg px-4 pb-4 md:rounded-2xl"
|
||||
}
|
||||
>
|
||||
@ -794,7 +794,6 @@ export function ExportContent({
|
||||
)}
|
||||
|
||||
<Input
|
||||
className="text-md"
|
||||
type="search"
|
||||
placeholder={t("export.name.placeholder")}
|
||||
value={name}
|
||||
@ -835,13 +834,11 @@ export function ExportContent({
|
||||
{selectedCaseId === "new" && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseNamePlaceholder")}
|
||||
value={singleNewCaseName}
|
||||
onChange={(e) => setSingleNewCaseName(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
||||
value={singleNewCaseDescription}
|
||||
onChange={(e) =>
|
||||
@ -988,7 +985,6 @@ export function ExportContent({
|
||||
{t("export.multiCamera.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
className="text-md"
|
||||
type="search"
|
||||
placeholder={t("export.multiCamera.namePlaceholder")}
|
||||
value={name}
|
||||
@ -1028,13 +1024,11 @@ export function ExportContent({
|
||||
{batchCaseSelection === "new" && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseNamePlaceholder")}
|
||||
value={newCaseName}
|
||||
onChange={(event) => setNewCaseName(event.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
||||
value={newCaseDescription}
|
||||
onChange={(event) =>
|
||||
@ -1049,20 +1043,15 @@ export function ExportContent({
|
||||
</Tabs>
|
||||
|
||||
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
|
||||
<DialogFooter
|
||||
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-2"}
|
||||
>
|
||||
<DialogFooter className="mt-3 sm:mt-0">
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
{activeTab === "export" ? (
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
aria-label={t("export.selectOrExport")}
|
||||
variant="select"
|
||||
disabled={isStartingExport}
|
||||
@ -1086,12 +1075,10 @@ export function ExportContent({
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
aria-label={t("export.multiCamera.exportButton", {
|
||||
count: selectedCameraCount,
|
||||
})}
|
||||
variant="select"
|
||||
size="sm"
|
||||
disabled={!canStartBatchExport}
|
||||
onClick={() => void startBatchExport()}
|
||||
>
|
||||
|
||||
@ -85,7 +85,7 @@ export default function ImagePicker({
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("imagePicker.search.placeholder")}
|
||||
className="text-md mb-3 md:text-sm"
|
||||
className="mb-3 md:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
|
||||
@ -3,7 +3,7 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import { Button } from "../ui/button";
|
||||
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
|
||||
import { LuBug, LuShare2 } from "react-icons/lu";
|
||||
import { LuBug, LuSearch, LuShare2 } from "react-icons/lu";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
|
||||
import {
|
||||
@ -46,6 +46,7 @@ const DRAWER_FEATURES = [
|
||||
"filter",
|
||||
"debug-replay",
|
||||
"share-timestamp",
|
||||
"motion-search",
|
||||
] as const;
|
||||
export type DrawerFeatures = (typeof DRAWER_FEATURES)[number];
|
||||
const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
|
||||
@ -54,6 +55,7 @@ const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
|
||||
"filter",
|
||||
"debug-replay",
|
||||
"share-timestamp",
|
||||
"motion-search",
|
||||
];
|
||||
|
||||
type MobileReviewSettingsDrawerProps = {
|
||||
@ -75,6 +77,7 @@ type MobileReviewSettingsDrawerProps = {
|
||||
setDebugReplayMode?: (mode: ExportMode) => void;
|
||||
setDebugReplayRange?: (range: TimeRange | undefined) => void;
|
||||
onShareTimestamp?: (timestamp: number) => void;
|
||||
onMotionSearch?: () => void;
|
||||
onUpdateFilter: (filter: ReviewFilter) => void;
|
||||
setRange: (range: TimeRange | undefined) => void;
|
||||
setMode: (mode: ExportMode) => void;
|
||||
@ -99,6 +102,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
setDebugReplayMode = () => {},
|
||||
setDebugReplayRange = () => {},
|
||||
onShareTimestamp = () => {},
|
||||
onMotionSearch,
|
||||
onUpdateFilter,
|
||||
setRange,
|
||||
setMode,
|
||||
@ -108,6 +112,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
"views/recording",
|
||||
"components/dialog",
|
||||
"views/replay",
|
||||
"views/events",
|
||||
"common",
|
||||
]);
|
||||
const isAdmin = useIsAdmin();
|
||||
@ -343,27 +348,6 @@ export default function MobileReviewSettingsDrawer({
|
||||
{t("export")}
|
||||
</Button>
|
||||
)}
|
||||
{features.includes("share-timestamp") && (
|
||||
<Button
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label={t("recording.shareTimestamp.label", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
onClick={() => {
|
||||
const initialTimestamp = Math.floor(currentTime);
|
||||
|
||||
setShareTimestampAtOpen(initialTimestamp);
|
||||
setCustomShareTimestamp(initialTimestamp);
|
||||
setSelectedShareOption("current");
|
||||
setDrawerMode("share-timestamp");
|
||||
}}
|
||||
>
|
||||
<LuShare2 className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
|
||||
{t("recording.shareTimestamp.label", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{features.includes("calendar") && (
|
||||
<Button
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
@ -390,6 +374,40 @@ export default function MobileReviewSettingsDrawer({
|
||||
{t("filter")}
|
||||
</Button>
|
||||
)}
|
||||
{features.includes("share-timestamp") && (
|
||||
<Button
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label={t("recording.shareTimestamp.label", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
onClick={() => {
|
||||
const initialTimestamp = Math.floor(currentTime);
|
||||
|
||||
setShareTimestampAtOpen(initialTimestamp);
|
||||
setCustomShareTimestamp(initialTimestamp);
|
||||
setSelectedShareOption("current");
|
||||
setDrawerMode("share-timestamp");
|
||||
}}
|
||||
>
|
||||
<LuShare2 className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
|
||||
{t("recording.shareTimestamp.label", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{features.includes("motion-search") && onMotionSearch && (
|
||||
<Button
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label={t("motionSearch.menuItem", { ns: "views/events" })}
|
||||
onClick={() => {
|
||||
onMotionSearch();
|
||||
setDrawerMode("none");
|
||||
}}
|
||||
>
|
||||
<LuSearch className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
|
||||
{t("motionSearch.menuItem", { ns: "views/events" })}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && features.includes("debug-replay") && (
|
||||
<Button
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
|
||||
@ -290,7 +290,6 @@ export default function MultiExportDialog({
|
||||
const newCaseInputs = (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseNamePlaceholder")}
|
||||
value={newCaseName}
|
||||
onChange={(event) => setNewCaseName(event.target.value)}
|
||||
@ -298,7 +297,6 @@ export default function MultiExportDialog({
|
||||
autoFocus={isDesktop}
|
||||
/>
|
||||
<Textarea
|
||||
className="text-md"
|
||||
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
||||
value={newCaseDescription}
|
||||
onChange={(event) => setNewCaseDescription(event.target.value)}
|
||||
@ -344,11 +342,7 @@ export default function MultiExportDialog({
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Button onClick={() => handleOpenChange(false)} disabled={isExporting}>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
@ -380,7 +374,7 @@ export default function MultiExportDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
<DialogFooter className="gap-2">{footer}</DialogFooter>
|
||||
<DialogFooter>{footer}</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@ -399,7 +393,7 @@ export default function MultiExportDialog({
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{body}
|
||||
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
|
||||
<DialogFooter className="mt-4">{footer}</DialogFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@ -117,30 +117,23 @@ export default function RoleChangeDialog({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
className="flex flex-1"
|
||||
onClick={() => onSave(selectedRole)}
|
||||
type="button"
|
||||
disabled={selectedRole === currentRole}
|
||||
>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
onClick={() => onSave(selectedRole)}
|
||||
type="button"
|
||||
disabled={selectedRole === currentRole}
|
||||
>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -450,36 +450,30 @@ export default function SetPasswordDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
type="submit"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -196,21 +196,16 @@ export function ShareTimestampContent({
|
||||
|
||||
{isDesktop && <Separator className="my-4 bg-secondary" />}
|
||||
|
||||
<DialogFooter
|
||||
className={cn("mt-4", !isDesktop && "flex flex-col-reverse gap-2")}
|
||||
>
|
||||
<DialogFooter className="mt-3 sm:mt-0">
|
||||
{onCancel && (
|
||||
<Button
|
||||
className={cn(!isDesktop && "w-full")}
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={cn(!isDesktop && "w-full")}
|
||||
variant="select"
|
||||
onClick={() => onShareTimestamp(Math.floor(selectedTimestamp))}
|
||||
>
|
||||
@ -338,7 +333,7 @@ function CustomTimestampSelector({
|
||||
/>
|
||||
<div className="my-3 h-px w-full bg-secondary" />
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="shareTimestamp"
|
||||
type="time"
|
||||
value={clock}
|
||||
|
||||
@ -145,7 +145,7 @@ export function AnnotationSettingsPane({
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="text-md mb-2">
|
||||
<div className="mb-2">
|
||||
{t("trackingDetails.annotationSettings.title")}
|
||||
</div>
|
||||
|
||||
|
||||
@ -131,7 +131,7 @@ export default function CreateFaceWizardDialog({
|
||||
forbiddenPattern={/#/}
|
||||
forbiddenErrorMessage={t("description.nameCannotContainHash")}
|
||||
>
|
||||
<div className="flex justify-end py-2">
|
||||
<div className="flex flex-col-reverse gap-2 py-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="select" type="submit">
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
@ -144,7 +144,7 @@ export default function CreateFaceWizardDialog({
|
||||
{t("steps.description.uploadFace", { name })}
|
||||
</div>
|
||||
<ImageEntry onSave={onUploadImage}>
|
||||
<div className="flex justify-end py-2">
|
||||
<div className="flex flex-col-reverse gap-2 py-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="select" type="submit">
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
@ -173,7 +173,7 @@ export default function CreateFaceWizardDialog({
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
|
||||
@ -1569,7 +1569,7 @@ function ObjectDetailsTab({
|
||||
{t("button.yes", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 text-white"
|
||||
className="flex-1"
|
||||
aria-label={t("button.no", { ns: "common" })}
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
@ -1706,7 +1706,7 @@ function ObjectDetailsTab({
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Textarea
|
||||
className="text-md h-32 md:text-sm"
|
||||
className="h-32 md:text-sm"
|
||||
placeholder={t("details.description.placeholder")}
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
|
||||
@ -821,7 +821,7 @@ export function TrackingDetails({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="capitalize">{label}</span>
|
||||
<div className="md:text-md flex items-center text-xs text-secondary-foreground">
|
||||
<div className="flex items-center text-xs text-secondary-foreground">
|
||||
{formattedStart ?? ""}
|
||||
{event.end_time != null ? (
|
||||
<> - {formattedEnd}</>
|
||||
@ -1072,7 +1072,7 @@ function LifecycleIconRow({
|
||||
|
||||
<div className="ml-2 flex w-full min-w-0 flex-1">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md flex items-start break-words text-left">
|
||||
<div className="flex items-start break-words text-left">
|
||||
{getLifecycleItemDescription(item)}
|
||||
</div>
|
||||
{/* Only show Score/Ratio/Area for object events, not for audio (heard) or manual API (external) events */}
|
||||
|
||||
@ -121,28 +121,22 @@ export default function DeleteCameraDialog({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
className="flex flex-1 text-white"
|
||||
onClick={handleDelete}
|
||||
disabled={!selectedCamera}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
onClick={handleDelete}
|
||||
disabled={!selectedCamera}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
@ -173,39 +167,31 @@ export default function DeleteCameraDialog({
|
||||
{t("cameraManagement.deleteCameraDialog.deleteExports")}
|
||||
</Label>
|
||||
</div>
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.back", { ns: "common" })}
|
||||
onClick={handleBack}
|
||||
type="button"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex flex-1 text-white"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>
|
||||
{t(
|
||||
"cameraManagement.deleteCameraDialog.confirmButton",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
t("cameraManagement.deleteCameraDialog.confirmButton")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
aria-label={t("button.back", { ns: "common" })}
|
||||
onClick={handleBack}
|
||||
type="button"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>
|
||||
{t("cameraManagement.deleteCameraDialog.confirmButton")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
t("cameraManagement.deleteCameraDialog.confirmButton")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -173,7 +173,7 @@ export function FrigatePlusDialog({
|
||||
{t("button.yes", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 text-white"
|
||||
className="flex-1"
|
||||
aria-label={t("button.no", { ns: "common" })}
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
|
||||
@ -7,9 +7,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
|
||||
@ -77,7 +75,7 @@ export default function MultiSelectDialog({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
|
||||
<DialogFooter className="pt-4">
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
{t("button.cancel")}
|
||||
</Button>
|
||||
|
||||
@ -144,18 +144,13 @@ export default function OptionAndInputDialog({
|
||||
<label className="text-sm font-medium text-secondary-foreground">
|
||||
{nameLabel}
|
||||
</label>
|
||||
<Input
|
||||
className="text-md"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-secondary-foreground">
|
||||
{descriptionLabel}
|
||||
</label>
|
||||
<Textarea
|
||||
className="text-md"
|
||||
value={descriptionValue}
|
||||
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||
rows={2}
|
||||
@ -164,10 +159,9 @@ export default function OptionAndInputDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
|
||||
@ -349,7 +349,7 @@ function TimeRangeFilterContent({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-row items-center justify-center">
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={selectedAfterHour}
|
||||
@ -389,7 +389,7 @@ function TimeRangeFilterContent({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-center">
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={
|
||||
|
||||
@ -9,8 +9,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type TextEntryDialogProps = {
|
||||
@ -63,7 +61,7 @@ export default function TextEntryDialog({
|
||||
forbiddenPattern={forbiddenPattern}
|
||||
forbiddenErrorMessage={forbiddenErrorMessage}
|
||||
>
|
||||
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
|
||||
@ -443,7 +443,7 @@ export default function LivePlayer({
|
||||
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
|
||||
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
|
||||
<div className="text-md">{t("streamOffline.title")}</div>
|
||||
<div>{t("streamOffline.title")}</div>
|
||||
<TbExclamationCircle className="size-6" />
|
||||
{!isCompact && (
|
||||
<p className="text-center text-sm">
|
||||
|
||||
@ -977,7 +977,7 @@ export default function CloneCameraDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between sm:space-x-0">
|
||||
<DialogFooter variant="split">
|
||||
<div className="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
{changeCount > 0 && (
|
||||
<>
|
||||
@ -1005,7 +1005,7 @@ export default function CloneCameraDialog({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
|
||||
@ -22,7 +22,6 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "../ui/sonner";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
@ -327,7 +326,6 @@ export default function MotionMaskEditPane({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<Heading as="h3" className="my-2">
|
||||
{polygon.name.length
|
||||
? t("masksAndZones.motionMasks.edit")
|
||||
|
||||
@ -31,7 +31,6 @@ import { FaCheckCircle } from "react-icons/fa";
|
||||
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "../ui/sonner";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
@ -335,7 +334,6 @@ export default function ObjectMaskEditPane({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<Heading as="h3" className="my-2">
|
||||
{polygon.name.length
|
||||
? t("masksAndZones.objectMasks.edit")
|
||||
|
||||
@ -24,12 +24,12 @@ import { toRGBColorString } from "@/utils/canvasUtil";
|
||||
import { Polygon, PolygonType } from "@/types/canvas";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { reviewQueries } from "@/utils/zoneEdutUtil";
|
||||
import IconWrapper from "../ui/icon-wrapper";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -368,8 +368,6 @@ export default function PolygonItem({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
|
||||
<div
|
||||
key={index}
|
||||
className="transition-background relative my-1.5 flex flex-row items-center justify-between rounded-lg p-1 duration-100"
|
||||
@ -511,7 +509,7 @@ export default function PolygonItem({
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{polygon.polygonSource === "override"
|
||||
|
||||
@ -59,9 +59,7 @@ export default function ExploreSettings({
|
||||
<div className={cn(className, "my-3 space-y-5 py-3 md:mt-0 md:py-0")}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">
|
||||
{t("explore.settings.defaultView.title")}
|
||||
</div>
|
||||
<div>{t("explore.settings.defaultView.title")}</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{t("explore.settings.defaultView.desc")}
|
||||
</div>
|
||||
@ -97,9 +95,7 @@ export default function ExploreSettings({
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex w-full flex-col space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">
|
||||
{t("explore.settings.gridColumns.title")}
|
||||
</div>
|
||||
<div>{t("explore.settings.gridColumns.title")}</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{t("explore.settings.gridColumns.desc")}
|
||||
</div>
|
||||
@ -162,9 +158,7 @@ export function SearchTypeContent({
|
||||
<div className="overflow-x-hidden">
|
||||
<DropdownMenuSeparator className="mb-3" />
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">
|
||||
{t("explore.settings.searchSource.label")}
|
||||
</div>
|
||||
<div>{t("explore.settings.searchSource.label")}</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{t("explore.settings.searchSource.desc")}
|
||||
</div>
|
||||
|
||||
@ -24,7 +24,6 @@ import { Label } from "../ui/label";
|
||||
import PolygonEditControls from "./PolygonEditControls";
|
||||
import { FaCheckCircle } from "react-icons/fa";
|
||||
import axios from "axios";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
@ -628,7 +627,6 @@ export default function ZoneEditPane({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<Heading as="h3" className="my-2">
|
||||
{polygon.name.length
|
||||
? t("masksAndZones.zones.edit")
|
||||
@ -709,7 +707,7 @@ export default function ZoneEditPane({
|
||||
<FormLabel>{t("masksAndZones.zones.inertia.title")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
placeholder="3"
|
||||
{...field}
|
||||
/>
|
||||
@ -734,7 +732,7 @@ export default function ZoneEditPane({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
placeholder="0"
|
||||
{...field}
|
||||
/>
|
||||
@ -864,7 +862,7 @@ export default function ZoneEditPane({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
{...field}
|
||||
onFocus={() => setActiveLine(1)}
|
||||
onBlur={() => setActiveLine(undefined)}
|
||||
@ -891,7 +889,7 @@ export default function ZoneEditPane({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
{...field}
|
||||
onFocus={() => setActiveLine(2)}
|
||||
onBlur={() => setActiveLine(undefined)}
|
||||
@ -918,7 +916,7 @@ export default function ZoneEditPane({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
{...field}
|
||||
onFocus={() => setActiveLine(3)}
|
||||
onBlur={() => setActiveLine(undefined)}
|
||||
@ -945,7 +943,7 @@ export default function ZoneEditPane({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
{...field}
|
||||
onFocus={() => setActiveLine(4)}
|
||||
onBlur={() => setActiveLine(undefined)}
|
||||
@ -972,7 +970,7 @@ export default function ZoneEditPane({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -87,7 +87,8 @@ export default function Step1NameCamera({
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => !val || val.startsWith("rtsp://"),
|
||||
(val) =>
|
||||
!val || val.startsWith("rtsp://") || val.startsWith("rtsps://"),
|
||||
t("cameraWizard.step1.errors.customUrlRtspRequired"),
|
||||
),
|
||||
})
|
||||
@ -171,7 +172,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="h-8"
|
||||
placeholder={t("cameraWizard.step1.cameraNamePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
@ -192,7 +193,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="h-8"
|
||||
placeholder="192.168.1.100"
|
||||
{...field}
|
||||
/>
|
||||
@ -212,7 +213,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="h-8"
|
||||
placeholder={t("cameraWizard.step1.usernamePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
@ -233,7 +234,7 @@ export default function Step1NameCamera({
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="text-md h-8 pr-10"
|
||||
className="h-8 pr-10"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.passwordPlaceholder",
|
||||
@ -316,7 +317,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="h-8"
|
||||
type="text"
|
||||
{...field}
|
||||
placeholder="80"
|
||||
@ -440,7 +441,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
className="h-8"
|
||||
placeholder="rtsp://username:password@host:port/path"
|
||||
{...field}
|
||||
/>
|
||||
@ -455,7 +456,7 @@ export default function Step1NameCamera({
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -626,7 +626,7 @@ function ProbeFooterButtons({
|
||||
<ActivityIndicator className="size-4" />
|
||||
{t("cameraWizard.step2.probing")}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onBack} disabled className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
@ -649,7 +649,7 @@ function ProbeFooterButtons({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-destructive">{probeError}</div>
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
@ -670,7 +670,7 @@ function ProbeFooterButtons({
|
||||
// If manual mode, show Continue when test succeeded, otherwise show Test (calls onManualTest)
|
||||
if (mode === "manual") {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
@ -707,7 +707,7 @@ function ProbeFooterButtons({
|
||||
|
||||
// Default probe footer
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -731,7 +731,7 @@ export default function Step3StreamConfig({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<div className="flex flex-col-reverse gap-2 pt-6 sm:flex-row sm:justify-end">
|
||||
{onBack && (
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
|
||||
@ -490,7 +490,7 @@ export default function Step4Validation({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<div className="flex flex-col-reverse gap-2 pt-6 sm:flex-row sm:justify-end">
|
||||
{onBack && (
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
|
||||
@ -176,20 +176,15 @@ export default function Step1NameAndType({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-2 pt-4 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="select"
|
||||
disabled={!form.formState.isValid}
|
||||
className="flex-1"
|
||||
className="sm:flex-1"
|
||||
>
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -109,20 +109,15 @@ export default function Step2ConfigureData({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="flex-1"
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-2 pt-4 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="select"
|
||||
disabled={!form.formState.isValid}
|
||||
className="flex-1"
|
||||
className="sm:flex-1"
|
||||
>
|
||||
{t("button.next", { ns: "common" })}
|
||||
</Button>
|
||||
|
||||
@ -181,20 +181,15 @@ export default function Step3ThresholdAndActions({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="flex-1"
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-2 pt-4 sm:flex-row sm:justify-end">
|
||||
<Button type="button" onClick={onBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
className="sm:flex-1"
|
||||
variant="select"
|
||||
>
|
||||
{isLoading && <ActivityIndicator className="mr-2 size-5" />}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
@ -59,15 +61,35 @@ const AlertDialogHeader = ({
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
const alertDialogFooterVariants = cva(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// 1-2 action buttons: full-width stacked on mobile, right-aligned auto on desktop.
|
||||
// [&>button] only targets real button children, so non-button siblings are untouched.
|
||||
actions: "sm:justify-end [&>button]:w-full sm:[&>button]:w-auto",
|
||||
// context content (text/popover) alongside actions: space-between on desktop.
|
||||
// flex-col (not -reverse) keeps the context above the buttons when stacked on mobile.
|
||||
split: "flex-col sm:items-center sm:justify-between",
|
||||
// alignment only; never touches children. Escape hatch for unusual content.
|
||||
plain: "sm:justify-end",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "actions",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
}: React.HTMLAttributes<HTMLDivElement> &
|
||||
VariantProps<typeof alertDialogFooterVariants>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
className={cn(alertDialogFooterVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -11,8 +11,7 @@ const buttonVariants = cva(
|
||||
variant: {
|
||||
default: "bg-secondary text-primary hover:bg-secondary/80",
|
||||
select: "bg-selected text-selected-foreground hover:bg-opacity-90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
destructive: "bg-destructive text-white hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useHistoryBack } from "@/hooks/use-history-back";
|
||||
@ -107,15 +108,32 @@ const DialogHeader = ({
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const dialogFooterVariants = cva("flex flex-col-reverse gap-2 sm:flex-row", {
|
||||
variants: {
|
||||
variant: {
|
||||
// 1-2 action buttons: full-width stacked on mobile, right-aligned auto on desktop.
|
||||
// [&>button] only targets real button children, so non-button siblings are untouched.
|
||||
actions: "sm:justify-end [&>button]:w-full sm:[&>button]:w-auto",
|
||||
// context content (text/popover) alongside actions: space-between on desktop.
|
||||
// flex-col (not -reverse) keeps the context above the buttons when stacked on mobile.
|
||||
split: "flex-col sm:items-center sm:justify-between",
|
||||
// alignment only; never touches children. Escape hatch for unusual content.
|
||||
plain: "sm:justify-end",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "actions",
|
||||
},
|
||||
});
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
}: React.HTMLAttributes<HTMLDivElement> &
|
||||
VariantProps<typeof dialogFooterVariants>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
className={cn(dialogFooterVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -56,11 +56,9 @@ export default function Events() {
|
||||
false,
|
||||
);
|
||||
|
||||
const [recording, setRecording] = useOverlayState<RecordingStartingPoint>(
|
||||
"recording",
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
const [recording, setRecording] = useOverlayState<
|
||||
RecordingStartingPoint | undefined
|
||||
>("recording", undefined, false);
|
||||
const [motionPreviewsCamera, setMotionPreviewsCamera] = useOverlayState<
|
||||
string | undefined
|
||||
>("motionPreviewsCamera", undefined);
|
||||
@ -668,6 +666,10 @@ export default function Events() {
|
||||
filter={reviewFilter}
|
||||
updateFilter={onUpdateFilter}
|
||||
refreshData={reloadData}
|
||||
onMotionSearch={(camera) => {
|
||||
setMotionSearchCamera(camera);
|
||||
setRecording(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -608,7 +608,6 @@ function Exports() {
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
className="text-white"
|
||||
aria-label="Delete Export"
|
||||
variant="destructive"
|
||||
onClick={() => onHandleDelete()}
|
||||
@ -658,7 +657,6 @@ function Exports() {
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
className="text-white"
|
||||
variant="destructive"
|
||||
onClick={() => void handleDeleteCase()}
|
||||
>
|
||||
@ -744,7 +742,7 @@ function Exports() {
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
className="text-md w-full bg-muted md:w-1/2"
|
||||
className="w-full bg-muted md:w-1/2"
|
||||
placeholder={t("search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@ -1277,8 +1275,8 @@ function CaseEditorDialog({
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
@ -1295,7 +1293,7 @@ function CaseEditorDialog({
|
||||
? t("button.save", { ns: "common" })
|
||||
: t("toolbar.newCase")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@ -1427,13 +1425,12 @@ function CaseAddExportDialog({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex-row justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
size="sm"
|
||||
disabled={selectedIds.length === 0 || isAdding}
|
||||
onClick={() => void handleAdd()}
|
||||
>
|
||||
|
||||
@ -567,7 +567,6 @@ function LibrarySelector({
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
onClick={() => {
|
||||
if (confirmDelete) {
|
||||
handleDeleteFace(confirmDelete);
|
||||
|
||||
@ -332,7 +332,7 @@ export default function Replay() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-white"
|
||||
className="flex items-center gap-2"
|
||||
disabled={isStopping}
|
||||
>
|
||||
{isStopping && <ActivityIndicator className="size-4" />}
|
||||
@ -355,10 +355,7 @@ export default function Replay() {
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleStop}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "destructive" }),
|
||||
"text-white",
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
>
|
||||
{t("page.confirmStop.confirm")}
|
||||
</AlertDialogAction>
|
||||
@ -687,7 +684,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-8/12 flex-row items-center justify-end">
|
||||
<div className="text-md mr-2 w-1/3">
|
||||
<div className="mr-2 w-1/3">
|
||||
<div className="flex flex-col items-end justify-end">
|
||||
<p className="mb-1.5 text-sm text-primary-variant">
|
||||
{t("debug.objectShapeFilterDrawing.score", {
|
||||
@ -697,7 +694,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
|
||||
{obj.score ? (obj.score * 100).toFixed(1).toString() : "-"}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-md mr-2 w-1/3">
|
||||
<div className="mr-2 w-1/3">
|
||||
<div className="flex flex-col items-end justify-end">
|
||||
<p className="mb-1.5 text-sm text-primary-variant">
|
||||
{t("debug.objectShapeFilterDrawing.ratio", {
|
||||
@ -707,7 +704,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
|
||||
{obj.ratio ? obj.ratio.toFixed(2).toString() : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-md mr-2 w-1/3">
|
||||
<div className="mr-2 w-1/3">
|
||||
<div className="flex flex-col items-end justify-end">
|
||||
<p className="mb-1.5 text-sm text-primary-variant">
|
||||
{t("debug.objectShapeFilterDrawing.area", {
|
||||
|
||||
@ -1616,7 +1616,7 @@ export default function Settings() {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" />
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
{!contentMobileOpen && (
|
||||
<div
|
||||
key={`mobile-menu-${selectedCamera}`}
|
||||
@ -1872,7 +1872,7 @@ export default function Settings() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<Toaster position="top-center" />
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="flex min-h-16 items-center justify-between border-b border-secondary p-3">
|
||||
<div className="mr-2 flex w-full items-center justify-between gap-3">
|
||||
<Heading as="h3" className="mb-0">
|
||||
|
||||
@ -668,7 +668,6 @@ function LibrarySelector({
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
onClick={() => {
|
||||
if (confirmDelete) {
|
||||
handleDeleteCategory(confirmDelete);
|
||||
|
||||
@ -1389,9 +1389,8 @@ function MotionReview({
|
||||
selectedCells={pendingFilterCells}
|
||||
onCellsChange={setPendingFilterCells}
|
||||
/>
|
||||
<DialogFooter className="justify-end gap-1">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={pendingFilterCells.size === 0}
|
||||
onClick={() => {
|
||||
setPendingFilterCells(new Set());
|
||||
@ -1432,9 +1431,7 @@ function MotionReview({
|
||||
<div className="space-y-4 py-2">
|
||||
{!isDesktop && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-md">
|
||||
{t("motionPreviews.mobileSettingsTitle")}
|
||||
</div>
|
||||
<div>{t("motionPreviews.mobileSettingsTitle")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("motionPreviews.mobileSettingsDesc")}
|
||||
</div>
|
||||
@ -1443,9 +1440,7 @@ function MotionReview({
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">
|
||||
{t("motionPreviews.speed")}
|
||||
</div>
|
||||
<div>{t("motionPreviews.speed")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("motionPreviews.speedDesc")}
|
||||
</div>
|
||||
@ -1474,7 +1469,7 @@ function MotionReview({
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">{t("motionPreviews.dim")}</div>
|
||||
<div>{t("motionPreviews.dim")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("motionPreviews.dimDesc")}
|
||||
</div>
|
||||
|
||||
@ -784,7 +784,7 @@ export default function LiveCameraView({
|
||||
transcription != null && (
|
||||
<div
|
||||
ref={transcriptionRef}
|
||||
className="text-md scrollbar-container absolute bottom-4 left-1/2 max-h-[15vh] w-[75%] -translate-x-1/2 overflow-y-auto rounded-lg bg-black/70 p-2 text-white md:w-[50%]"
|
||||
className="scrollbar-container absolute bottom-4 left-1/2 max-h-[15vh] w-[75%] -translate-x-1/2 overflow-y-auto rounded-lg bg-black/70 p-2 text-white md:w-[50%]"
|
||||
>
|
||||
{transcription}
|
||||
</div>
|
||||
|
||||
@ -3,9 +3,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
||||
import { FaArrowRight, FaCalendarAlt, FaCheckCircle } from "react-icons/fa";
|
||||
import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
|
||||
import { LuHand, LuPencil } from "react-icons/lu";
|
||||
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { ASPECT_PORTRAIT_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -112,40 +114,35 @@ export default function MotionSearchDialog({
|
||||
}: MotionSearchDialogProps) {
|
||||
const { t } = useTranslation(["views/motionSearch", "common"]);
|
||||
const apiHost = useApiHost();
|
||||
const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const containerWidth = containerSize.width;
|
||||
const containerHeight = containerSize.height;
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
const rect = containerNode.getBoundingClientRect();
|
||||
setContainerSize((prev) =>
|
||||
prev.width === rect.width && prev.height === rect.height
|
||||
? prev
|
||||
: { width: rect.width, height: rect.height },
|
||||
);
|
||||
};
|
||||
|
||||
measure();
|
||||
|
||||
const observer = new ResizeObserver(() => measure());
|
||||
observer.observe(containerNode);
|
||||
return () => observer.disconnect();
|
||||
}, [containerNode]);
|
||||
const [panMode, setPanMode] = useState(false);
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (!selectedCamera) return undefined;
|
||||
return config.cameras[selectedCamera];
|
||||
}, [config, selectedCamera]);
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (!cameraConfig) {
|
||||
return 16 / 9;
|
||||
}
|
||||
|
||||
return cameraConfig.detect.width / cameraConfig.detect.height;
|
||||
}, [cameraConfig]);
|
||||
|
||||
// Determine camera aspect ratio category
|
||||
const cameraAspect = useMemo(() => {
|
||||
if (!aspectRatio) {
|
||||
return "normal";
|
||||
} else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
|
||||
return "wide";
|
||||
} else if (aspectRatio < ASPECT_PORTRAIT_LAYOUT) {
|
||||
return "tall";
|
||||
} else {
|
||||
return "normal";
|
||||
}
|
||||
}, [aspectRatio]);
|
||||
|
||||
const polygonClosed = useMemo(
|
||||
() => !isDrawingROI && polygonPoints.length >= 3,
|
||||
[isDrawingROI, polygonPoints.length],
|
||||
@ -169,30 +166,9 @@ export default function MotionSearchDialog({
|
||||
setIsDrawingROI(true);
|
||||
}, [isSearching, polygonPoints.length, setIsDrawingROI, setPolygonPoints]);
|
||||
|
||||
const imageSize = useMemo(() => {
|
||||
if (!containerWidth || !containerHeight || !cameraConfig) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
const cameraAspectRatio =
|
||||
cameraConfig.detect.width / cameraConfig.detect.height;
|
||||
const availableAspectRatio = containerWidth / containerHeight;
|
||||
|
||||
if (availableAspectRatio >= cameraAspectRatio) {
|
||||
return {
|
||||
width: containerHeight * cameraAspectRatio,
|
||||
height: containerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: containerWidth,
|
||||
height: containerWidth / cameraAspectRatio,
|
||||
};
|
||||
}, [containerWidth, containerHeight, cameraConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
setImageLoaded(false);
|
||||
setPanMode(false);
|
||||
}, [selectedCamera]);
|
||||
|
||||
const Overlay = isDesktop ? Dialog : Drawer;
|
||||
@ -267,7 +243,13 @@ export default function MotionSearchDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
|
||||
<TransformWrapper
|
||||
minScale={1.0}
|
||||
wheel={{ smoothStep: 0.005 }}
|
||||
panning={{ disabled: !isDesktop && !panMode }}
|
||||
pinch={{ disabled: !isDesktop && !panMode }}
|
||||
doubleClick={{ disabled: !isDesktop && !panMode }}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
@ -281,18 +263,16 @@ export default function MotionSearchDialog({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={setContainerNode}
|
||||
className="relative flex w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary"
|
||||
style={{ aspectRatio: "16 / 9" }}
|
||||
className={cn(
|
||||
"relative mx-auto flex items-center justify-center overflow-hidden rounded-lg border bg-secondary",
|
||||
cameraAspect === "tall"
|
||||
? "max-h-[50dvh] lg:max-h-[60dvh]"
|
||||
: "w-full",
|
||||
)}
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
{selectedCamera && cameraConfig ? (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: imageSize.width || "100%",
|
||||
height: imageSize.height || "100%",
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
<img
|
||||
alt={t("dialog.previewAlt", {
|
||||
camera: selectedCamera,
|
||||
@ -320,6 +300,7 @@ export default function MotionSearchDialog({
|
||||
isDrawing={isDrawingROI}
|
||||
setIsDrawing={setIsDrawingROI}
|
||||
isInteractive={true}
|
||||
panMode={panMode}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@ -341,11 +322,41 @@ export default function MotionSearchDialog({
|
||||
{polygonClosed && <FaCheckCircle className="ml-2 size-5" />}
|
||||
</div>
|
||||
<div className="flex flex-row justify-center gap-2">
|
||||
{!isDesktop && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={panMode ? "select" : "default"}
|
||||
className="size-8 rounded-md p-1.5"
|
||||
aria-label={
|
||||
panMode
|
||||
? t("polygonControls.moveMode")
|
||||
: t("polygonControls.drawMode")
|
||||
}
|
||||
onClick={() => setPanMode((prev) => !prev)}
|
||||
>
|
||||
{panMode ? (
|
||||
<LuHand className="text-selected-foreground" />
|
||||
) : (
|
||||
<LuPencil className="text-secondary-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{panMode
|
||||
? t("polygonControls.moveMode")
|
||||
: t("polygonControls.drawMode")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="size-6 rounded-md p-1"
|
||||
className={cn(
|
||||
"rounded-md",
|
||||
isDesktop ? "size-6 p-1" : "size-8 p-1.5",
|
||||
)}
|
||||
aria-label={t("polygonControls.undo")}
|
||||
disabled={polygonPoints.length === 0 || isSearching}
|
||||
onClick={undoPolygonPoint}
|
||||
@ -361,7 +372,10 @@ export default function MotionSearchDialog({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="size-6 rounded-md p-1"
|
||||
className={cn(
|
||||
"rounded-md",
|
||||
isDesktop ? "size-6 p-1" : "size-8 p-1.5",
|
||||
)}
|
||||
aria-label={t("polygonControls.reset")}
|
||||
disabled={polygonPoints.length === 0 || isSearching}
|
||||
onClick={resetPolygon}
|
||||
@ -429,7 +443,7 @@ export default function MotionSearchDialog({
|
||||
<Slider
|
||||
id="frameSkip"
|
||||
min={1}
|
||||
max={60}
|
||||
max={120}
|
||||
step={1}
|
||||
value={[frameSkip]}
|
||||
onValueChange={([value]) => setFrameSkip(value)}
|
||||
@ -630,7 +644,7 @@ function SearchRangeSelector({
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={startClock}
|
||||
@ -696,7 +710,7 @@ function SearchRangeSelector({
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="endTime"
|
||||
type="time"
|
||||
value={endClock}
|
||||
|
||||
@ -14,6 +14,7 @@ type MotionSearchROICanvasProps = {
|
||||
isDrawing: boolean;
|
||||
setIsDrawing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isInteractive?: boolean;
|
||||
panMode?: boolean;
|
||||
motionHeatmap?: Record<string, number> | null;
|
||||
showMotionHeatmap?: boolean;
|
||||
};
|
||||
@ -26,6 +27,7 @@ export default function MotionSearchROICanvas({
|
||||
isDrawing,
|
||||
setIsDrawing,
|
||||
isInteractive = true,
|
||||
panMode = false,
|
||||
motionHeatmap,
|
||||
showMotionHeatmap = false,
|
||||
}: MotionSearchROICanvasProps) {
|
||||
@ -341,7 +343,9 @@ export default function MotionSearchROICanvas({
|
||||
ref={setContainerNode}
|
||||
className={cn(
|
||||
"absolute inset-0 z-10",
|
||||
isInteractive ? "pointer-events-auto" : "pointer-events-none",
|
||||
isInteractive && !panMode
|
||||
? "pointer-events-auto"
|
||||
: "pointer-events-none",
|
||||
)}
|
||||
style={{ cursor: isDrawing ? "crosshair" : "default" }}
|
||||
>
|
||||
|
||||
@ -146,7 +146,7 @@ export default function MotionSearchView({
|
||||
const [parallelMode, setParallelMode] = useState(false);
|
||||
const [threshold, setThreshold] = useState(30);
|
||||
const [minArea, setMinArea] = useState(20);
|
||||
const [frameSkip, setFrameSkip] = useState(10);
|
||||
const [frameSkip, setFrameSkip] = useState(30);
|
||||
const [maxResults, setMaxResults] = useState(25);
|
||||
|
||||
// Job state
|
||||
@ -846,7 +846,13 @@ export default function MotionSearchView({
|
||||
responseData.errors;
|
||||
|
||||
if (Array.isArray(apiMessage)) {
|
||||
errorMessage = apiMessage.join(", ");
|
||||
errorMessage = apiMessage
|
||||
.map((item) =>
|
||||
typeof item === "string"
|
||||
? item
|
||||
: ((item as { msg?: string })?.msg ?? JSON.stringify(item)),
|
||||
)
|
||||
.join(", ");
|
||||
} else if (typeof apiMessage === "string") {
|
||||
errorMessage = apiMessage;
|
||||
} else if (apiMessage) {
|
||||
@ -1052,7 +1058,6 @@ export default function MotionSearchView({
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void cancelMotionSearchJob(jobId, jobCamera);
|
||||
|
||||
@ -95,6 +95,7 @@ type RecordingViewProps = {
|
||||
filter?: ReviewFilter;
|
||||
updateFilter: (newFilter: ReviewFilter) => void;
|
||||
refreshData?: () => void;
|
||||
onMotionSearch?: (camera: string) => void;
|
||||
};
|
||||
export function RecordingView({
|
||||
startCamera,
|
||||
@ -107,6 +108,7 @@ export function RecordingView({
|
||||
filter,
|
||||
updateFilter,
|
||||
refreshData,
|
||||
onMotionSearch,
|
||||
}: RecordingViewProps) {
|
||||
const { t } = useTranslation(["views/events", "components/dialog"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@ -725,6 +727,9 @@ export function RecordingView({
|
||||
setCustomShareTimestamp(initialTimestamp);
|
||||
setShareTimestampOpen(true);
|
||||
}}
|
||||
onMotionSearchClick={
|
||||
onMotionSearch ? () => onMotionSearch(mainCamera) : undefined
|
||||
}
|
||||
onDebugReplayClick={
|
||||
isAdmin
|
||||
? () => {
|
||||
@ -807,6 +812,9 @@ export function RecordingView({
|
||||
}
|
||||
}}
|
||||
onShareTimestamp={onShareReviewLink}
|
||||
onMotionSearch={
|
||||
onMotionSearch ? () => onMotionSearch(mainCamera) : undefined
|
||||
}
|
||||
onUpdateFilter={updateFilter}
|
||||
setRange={setExportRange}
|
||||
setMode={setExportMode}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import useSWR from "swr";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { User } from "@/types/user";
|
||||
@ -790,7 +789,6 @@ export default function AuthenticationView({
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
||||
{section === "users" && UsersSection}
|
||||
{section === "roles" && RolesSection}
|
||||
|
||||
@ -250,7 +250,7 @@ export default function CameraManagementView({
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="mb-2 flex max-w-48 items-center gap-2 text-white"
|
||||
className="mb-2 flex max-w-48 items-center gap-2"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
{t("cameraManagement.deleteCamera")}
|
||||
@ -261,7 +261,7 @@ export default function CameraManagementView({
|
||||
{enabledCameras.length + disabledCameras.length > 0 && (
|
||||
<div className="mb-5 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md font-medium">
|
||||
<div className="font-medium">
|
||||
{t("cameraManagement.clone.sectionTitle")}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@ -22,7 +22,6 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@ -598,7 +597,6 @@ export default function DetectorsAndModelSettingsView({
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col md:pr-2">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="mb-1 flex items-center justify-between gap-4 pt-2">
|
||||
<div className="flex max-w-5xl flex-col">
|
||||
<Heading as="h4">{t("detectorsAndModel.title")}</Heading>
|
||||
|
||||
@ -306,9 +306,7 @@ export default function EnrichmentsSettingsView({
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col space-y-6">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">
|
||||
{t("enrichments.semanticSearch.modelSize.label")}
|
||||
</div>
|
||||
<div>{t("enrichments.semanticSearch.modelSize.label")}</div>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<Trans ns="views/settings">
|
||||
@ -436,9 +434,7 @@ export default function EnrichmentsSettingsView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">
|
||||
{t("enrichments.faceRecognition.modelSize.label")}
|
||||
</div>
|
||||
<div>{t("enrichments.faceRecognition.modelSize.label")}</div>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<Trans ns="views/settings">
|
||||
|
||||
@ -4,7 +4,6 @@ import { Link, useNavigate } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Heading from "@/components/ui/heading";
|
||||
@ -35,7 +34,6 @@ export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col md:pr-2">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="w-full max-w-5xl space-y-6 pt-2">
|
||||
<div className="flex flex-col gap-0">
|
||||
<Heading as="h4" className="mb-2">
|
||||
|
||||
@ -446,10 +446,7 @@ export default function Go2RtcStreamsSettingsView({
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(
|
||||
buttonVariants({ variant: "destructive" }),
|
||||
"text-white",
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
onClick={() => deleteDialog && deleteStream(deleteDialog)}
|
||||
>
|
||||
{t("go2rtcStreams.deleteStream")}
|
||||
@ -533,7 +530,6 @@ function RenameStreamDialog({
|
||||
<div className="space-y-2 py-2">
|
||||
<Label>{t("go2rtcStreams.newStreamName")}</Label>
|
||||
<Input
|
||||
className="text-md"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@ -547,7 +543,7 @@ function RenameStreamDialog({
|
||||
<p className="text-xs text-destructive">{nameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-end md:gap-0">
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button>{t("button.cancel", { ns: "common" })}</Button>
|
||||
</DialogClose>
|
||||
@ -614,7 +610,6 @@ function AddStreamDialog({
|
||||
<Label>{t("go2rtcStreams.streamName")}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
className="text-md"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && canSubmit) {
|
||||
@ -628,7 +623,7 @@ function AddStreamDialog({
|
||||
<p className="text-xs text-destructive">{nameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-end md:gap-0">
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button>{t("button.cancel", { ns: "common" })}</Button>
|
||||
</DialogClose>
|
||||
@ -924,7 +919,7 @@ function StreamUrlEntry({
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
className="text-md h-8 pr-10"
|
||||
className="h-8 pr-10"
|
||||
value={baseUrlForDisplay}
|
||||
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
|
||||
@ -15,7 +15,6 @@ import {
|
||||
} from "@/components/ui/hover-card";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
@ -730,7 +729,6 @@ export default function MasksAndZonesView({
|
||||
<>
|
||||
{cameraConfig && editingPolygons && (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12 md:min-w-0 md:shrink-0">
|
||||
{editPane == "zone" && (
|
||||
<ZoneEditPane
|
||||
@ -793,7 +791,7 @@ export default function MasksAndZonesView({
|
||||
<div className="my-3 flex flex-row items-center justify-between">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="text-md cursor-default">
|
||||
<div className="cursor-default">
|
||||
{t("masksAndZones.zones.label")}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
@ -871,7 +869,7 @@ export default function MasksAndZonesView({
|
||||
<div className="my-3 flex flex-row items-center justify-between">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="text-md cursor-default">
|
||||
<div className="cursor-default">
|
||||
{t("masksAndZones.motionMasks.label")}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
@ -953,7 +951,7 @@ export default function MasksAndZonesView({
|
||||
<div className="my-3 flex flex-row items-center justify-between">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="text-md cursor-default">
|
||||
<div className="cursor-default">
|
||||
{t("masksAndZones.objectMasks.label")}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user