mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-01 04:44:52 +03:00
commit
f72418ceb4
@ -166,6 +166,10 @@ In this example:
|
|||||||
- If no mapping matches, Frigate falls back to `default_role` if configured.
|
- If no mapping matches, Frigate falls back to `default_role` if configured.
|
||||||
- If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name.
|
- If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name.
|
||||||
|
|
||||||
|
**Note on matching semantics:**
|
||||||
|
|
||||||
|
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
|
||||||
|
|
||||||
#### Port Considerations
|
#### Port Considerations
|
||||||
|
|
||||||
**Authenticated Port (8971)**
|
**Authenticated Port (8971)**
|
||||||
|
|||||||
@ -190,7 +190,7 @@ There are improved capabilities in newer GPU architectures that TensorRT can ben
|
|||||||
|
|
||||||
#### Compatibility References:
|
#### Compatibility References:
|
||||||
|
|
||||||
[NVIDIA TensorRT Support Matrix](https://docs.nvidia.com/deeplearning/tensorrt/archives/tensorrt-841/support-matrix/index.html)
|
[NVIDIA TensorRT Support Matrix](https://docs.nvidia.com/deeplearning/tensorrt-rtx/latest/getting-started/support-matrix.html)
|
||||||
|
|
||||||
[NVIDIA CUDA Compatibility](https://docs.nvidia.com/deploy/cuda-compatibility/index.html)
|
[NVIDIA CUDA Compatibility](https://docs.nvidia.com/deploy/cuda-compatibility/index.html)
|
||||||
|
|
||||||
|
|||||||
@ -439,10 +439,11 @@ def resolve_role(
|
|||||||
Determine the effective role for a request based on proxy headers and configuration.
|
Determine the effective role for a request based on proxy headers and configuration.
|
||||||
|
|
||||||
Order of resolution:
|
Order of resolution:
|
||||||
1. If a role header is defined in proxy_config.header_map.role:
|
1. If a role header is defined in proxy_config.header_map.role:
|
||||||
- If a role_map is configured, treat the header as group claims
|
- If a role_map is configured, treat the header as group claims
|
||||||
(split by proxy_config.separator) and map to roles.
|
(split by proxy_config.separator) and map to roles.
|
||||||
- If no role_map is configured, treat the header as role names directly.
|
Admin matches short-circuit to admin.
|
||||||
|
- If no role_map is configured, treat the header as role names directly.
|
||||||
2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'.
|
2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -492,6 +493,12 @@ def resolve_role(
|
|||||||
}
|
}
|
||||||
logger.debug("Matched roles from role_map: %s", matched_roles)
|
logger.debug("Matched roles from role_map: %s", matched_roles)
|
||||||
|
|
||||||
|
# If admin matches, prioritize it to avoid accidental downgrade when
|
||||||
|
# users belong to both admin and lower-privilege groups.
|
||||||
|
if "admin" in matched_roles and "admin" in config_roles:
|
||||||
|
logger.debug("Resolved role (with role_map) to 'admin'.")
|
||||||
|
return "admin"
|
||||||
|
|
||||||
if matched_roles:
|
if matched_roles:
|
||||||
resolved = next(
|
resolved = next(
|
||||||
(r for r in config_roles if r in matched_roles), validated_default
|
(r for r in config_roles if r in matched_roles), validated_default
|
||||||
|
|||||||
@ -69,6 +69,25 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(tags=[Tags.events])
|
router = APIRouter(tags=[Tags.events])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_attribute_filter_clause(attributes: str):
|
||||||
|
filtered_attributes = [
|
||||||
|
attr.strip() for attr in attributes.split(",") if attr.strip()
|
||||||
|
]
|
||||||
|
attribute_clauses = []
|
||||||
|
|
||||||
|
for attr in filtered_attributes:
|
||||||
|
attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*')
|
||||||
|
|
||||||
|
escaped_attr = json.dumps(attr, ensure_ascii=True)[1:-1]
|
||||||
|
if escaped_attr != attr:
|
||||||
|
attribute_clauses.append(Event.data.cast("text") % f'*:"{escaped_attr}"*')
|
||||||
|
|
||||||
|
if not attribute_clauses:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return reduce(operator.or_, attribute_clauses)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/events",
|
"/events",
|
||||||
response_model=list[EventResponse],
|
response_model=list[EventResponse],
|
||||||
@ -193,14 +212,9 @@ def events(
|
|||||||
|
|
||||||
if attributes != "all":
|
if attributes != "all":
|
||||||
# Custom classification results are stored as data[model_name] = result_value
|
# Custom classification results are stored as data[model_name] = result_value
|
||||||
filtered_attributes = attributes.split(",")
|
attribute_clause = _build_attribute_filter_clause(attributes)
|
||||||
attribute_clauses = []
|
if attribute_clause is not None:
|
||||||
|
clauses.append(attribute_clause)
|
||||||
for attr in filtered_attributes:
|
|
||||||
attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*')
|
|
||||||
|
|
||||||
attribute_clause = reduce(operator.or_, attribute_clauses)
|
|
||||||
clauses.append(attribute_clause)
|
|
||||||
|
|
||||||
if recognized_license_plate != "all":
|
if recognized_license_plate != "all":
|
||||||
filtered_recognized_license_plates = recognized_license_plate.split(",")
|
filtered_recognized_license_plates = recognized_license_plate.split(",")
|
||||||
@ -508,7 +522,7 @@ def events_search(
|
|||||||
cameras = params.cameras
|
cameras = params.cameras
|
||||||
labels = params.labels
|
labels = params.labels
|
||||||
sub_labels = params.sub_labels
|
sub_labels = params.sub_labels
|
||||||
attributes = params.attributes
|
attributes = unquote(params.attributes)
|
||||||
zones = params.zones
|
zones = params.zones
|
||||||
after = params.after
|
after = params.after
|
||||||
before = params.before
|
before = params.before
|
||||||
@ -607,13 +621,9 @@ def events_search(
|
|||||||
|
|
||||||
if attributes != "all":
|
if attributes != "all":
|
||||||
# Custom classification results are stored as data[model_name] = result_value
|
# Custom classification results are stored as data[model_name] = result_value
|
||||||
filtered_attributes = attributes.split(",")
|
attribute_clause = _build_attribute_filter_clause(attributes)
|
||||||
attribute_clauses = []
|
if attribute_clause is not None:
|
||||||
|
event_filters.append(attribute_clause)
|
||||||
for attr in filtered_attributes:
|
|
||||||
attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*')
|
|
||||||
|
|
||||||
event_filters.append(reduce(operator.or_, attribute_clauses))
|
|
||||||
|
|
||||||
if zones != "all":
|
if zones != "all":
|
||||||
zone_clauses = []
|
zone_clauses = []
|
||||||
|
|||||||
@ -658,6 +658,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
|||||||
def handle_request(self, topic, request_data):
|
def handle_request(self, topic, request_data):
|
||||||
if topic == EmbeddingsRequestEnum.reload_classification_model.value:
|
if topic == EmbeddingsRequestEnum.reload_classification_model.value:
|
||||||
if request_data.get("model_name") == self.model_config.name:
|
if request_data.get("model_name") == self.model_config.name:
|
||||||
|
self.__build_detector()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully loaded updated model for {self.model_config.name}"
|
f"Successfully loaded updated model for {self.model_config.name}"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from typing import Dict
|
|||||||
|
|
||||||
from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
|
from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.classification import ObjectClassificationType
|
||||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
from frigate.util.builtin import to_relative_box
|
from frigate.util.builtin import to_relative_box
|
||||||
@ -247,6 +248,18 @@ class EventProcessor(threading.Thread):
|
|||||||
"recognized_license_plate"
|
"recognized_license_plate"
|
||||||
][1]
|
][1]
|
||||||
|
|
||||||
|
# only overwrite attribute-type custom model fields in the database if they're set
|
||||||
|
for name, model_config in self.config.classification.custom.items():
|
||||||
|
if (
|
||||||
|
model_config.object_config
|
||||||
|
and model_config.object_config.classification_type
|
||||||
|
== ObjectClassificationType.attribute
|
||||||
|
):
|
||||||
|
value = event_data.get(name)
|
||||||
|
if value is not None:
|
||||||
|
event[Event.data][name] = value[0]
|
||||||
|
event[Event.data][f"{name}_score"] = value[1]
|
||||||
|
|
||||||
(
|
(
|
||||||
Event.insert(event)
|
Event.insert(event)
|
||||||
.on_conflict(
|
.on_conflict(
|
||||||
|
|||||||
@ -168,6 +168,57 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
assert events[0]["id"] == id
|
assert events[0]["id"] == id
|
||||||
assert events[1]["id"] == id2
|
assert events[1]["id"] == id2
|
||||||
|
|
||||||
|
def test_get_event_list_match_multilingual_attribute(self):
|
||||||
|
event_id = "123456.zh"
|
||||||
|
attribute = "中文标签"
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
super().insert_mock_event(event_id, data={"custom_attr": attribute})
|
||||||
|
|
||||||
|
events = client.get("/events", params={"attributes": attribute}).json()
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["id"] == event_id
|
||||||
|
|
||||||
|
events = client.get(
|
||||||
|
"/events", params={"attributes": "%E4%B8%AD%E6%96%87%E6%A0%87%E7%AD%BE"}
|
||||||
|
).json()
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["id"] == event_id
|
||||||
|
|
||||||
|
def test_events_search_match_multilingual_attribute(self):
|
||||||
|
event_id = "123456.zh.search"
|
||||||
|
attribute = "中文标签"
|
||||||
|
mock_embeddings = Mock()
|
||||||
|
mock_embeddings.search_thumbnail.return_value = [(event_id, 0.05)]
|
||||||
|
|
||||||
|
self.app.frigate_config.semantic_search.enabled = True
|
||||||
|
self.app.embeddings = mock_embeddings
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
super().insert_mock_event(event_id, data={"custom_attr": attribute})
|
||||||
|
|
||||||
|
events = client.get(
|
||||||
|
"/events/search",
|
||||||
|
params={
|
||||||
|
"search_type": "similarity",
|
||||||
|
"event_id": event_id,
|
||||||
|
"attributes": attribute,
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["id"] == event_id
|
||||||
|
|
||||||
|
events = client.get(
|
||||||
|
"/events/search",
|
||||||
|
params={
|
||||||
|
"search_type": "similarity",
|
||||||
|
"event_id": event_id,
|
||||||
|
"attributes": "%E4%B8%AD%E6%96%87%E6%A0%87%E7%AD%BE",
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["id"] == event_id
|
||||||
|
|
||||||
def test_get_good_event(self):
|
def test_get_good_event(self):
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,21 @@ class TestProxyRoleResolution(unittest.TestCase):
|
|||||||
role = resolve_role(headers, self.proxy_config, self.config_roles)
|
role = resolve_role(headers, self.proxy_config, self.config_roles)
|
||||||
self.assertEqual(role, "admin")
|
self.assertEqual(role, "admin")
|
||||||
|
|
||||||
|
def test_role_map_or_matching(self):
|
||||||
|
config = self.proxy_config
|
||||||
|
config.header_map.role_map = {
|
||||||
|
"admin": ["group_admin", "group_privileged"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# OR semantics: a single matching group should map to the role
|
||||||
|
headers = {"x-remote-role": "group_admin"}
|
||||||
|
role = resolve_role(headers, config, self.config_roles)
|
||||||
|
self.assertEqual(role, "admin")
|
||||||
|
|
||||||
|
headers = {"x-remote-role": "group_admin|group_privileged"}
|
||||||
|
role = resolve_role(headers, config, self.config_roles)
|
||||||
|
self.assertEqual(role, "admin")
|
||||||
|
|
||||||
def test_direct_role_header_with_separator(self):
|
def test_direct_role_header_with_separator(self):
|
||||||
config = self.proxy_config
|
config = self.proxy_config
|
||||||
config.header_map.role_map = None # disable role_map
|
config.header_map.role_map = None # disable role_map
|
||||||
|
|||||||
@ -33,6 +33,7 @@ from frigate.config.camera.updater import (
|
|||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdateSubscriber,
|
CameraConfigUpdateSubscriber,
|
||||||
)
|
)
|
||||||
|
from frigate.config.classification import ObjectClassificationType
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
FAST_QUEUE_TIMEOUT,
|
FAST_QUEUE_TIMEOUT,
|
||||||
UPDATE_CAMERA_ACTIVITY,
|
UPDATE_CAMERA_ACTIVITY,
|
||||||
@ -759,8 +760,16 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
|
|
||||||
self.update_mqtt_motion(camera, frame_time, motion_boxes)
|
self.update_mqtt_motion(camera, frame_time, motion_boxes)
|
||||||
|
|
||||||
|
attribute_model_names = [
|
||||||
|
name
|
||||||
|
for name, model_config in self.config.classification.custom.items()
|
||||||
|
if model_config.object_config
|
||||||
|
and model_config.object_config.classification_type
|
||||||
|
== ObjectClassificationType.attribute
|
||||||
|
]
|
||||||
tracked_objects = [
|
tracked_objects = [
|
||||||
o.to_dict() for o in camera_state.tracked_objects.values()
|
o.to_dict(attribute_model_names=attribute_model_names)
|
||||||
|
for o in camera_state.tracked_objects.values()
|
||||||
]
|
]
|
||||||
|
|
||||||
# publish info on this frame
|
# publish info on this frame
|
||||||
|
|||||||
@ -376,7 +376,10 @@ class TrackedObject:
|
|||||||
)
|
)
|
||||||
return (thumb_update, significant_change, path_update, autotracker_update)
|
return (thumb_update, significant_change, path_update, autotracker_update)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(
|
||||||
|
self,
|
||||||
|
attribute_model_names: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
event = {
|
event = {
|
||||||
"id": self.obj_data["id"],
|
"id": self.obj_data["id"],
|
||||||
"camera": self.camera_config.name,
|
"camera": self.camera_config.name,
|
||||||
@ -411,6 +414,11 @@ class TrackedObject:
|
|||||||
"path_data": self.path_data.copy(),
|
"path_data": self.path_data.copy(),
|
||||||
"recognized_license_plate": self.obj_data.get("recognized_license_plate"),
|
"recognized_license_plate": self.obj_data.get("recognized_license_plate"),
|
||||||
}
|
}
|
||||||
|
if attribute_model_names is not None:
|
||||||
|
for name in attribute_model_names:
|
||||||
|
value = self.obj_data.get(name)
|
||||||
|
if value is not None:
|
||||||
|
event[name] = value
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|||||||
@ -214,6 +214,7 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.latest_valid_segment_time: float = 0
|
self.latest_valid_segment_time: float = 0
|
||||||
self.latest_invalid_segment_time: float = 0
|
self.latest_invalid_segment_time: float = 0
|
||||||
self.latest_cache_segment_time: float = 0
|
self.latest_cache_segment_time: float = 0
|
||||||
|
self.record_enable_time: datetime | None = None
|
||||||
|
|
||||||
def _update_enabled_state(self) -> bool:
|
def _update_enabled_state(self) -> bool:
|
||||||
"""Fetch the latest config and update enabled state."""
|
"""Fetch the latest config and update enabled state."""
|
||||||
@ -261,6 +262,9 @@ class CameraWatchdog(threading.Thread):
|
|||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
if self._update_enabled_state():
|
if self._update_enabled_state():
|
||||||
self.start_all_ffmpeg()
|
self.start_all_ffmpeg()
|
||||||
|
# If recording is enabled at startup, set the grace period timer
|
||||||
|
if self.config.record.enabled:
|
||||||
|
self.record_enable_time = datetime.now().astimezone(timezone.utc)
|
||||||
|
|
||||||
time.sleep(self.sleeptime)
|
time.sleep(self.sleeptime)
|
||||||
while not self.stop_event.wait(self.sleeptime):
|
while not self.stop_event.wait(self.sleeptime):
|
||||||
@ -270,13 +274,15 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.logger.debug(f"Enabling camera {self.config.name}")
|
self.logger.debug(f"Enabling camera {self.config.name}")
|
||||||
self.start_all_ffmpeg()
|
self.start_all_ffmpeg()
|
||||||
|
|
||||||
# reset all timestamps
|
# reset all timestamps and record the enable time for grace period
|
||||||
self.latest_valid_segment_time = 0
|
self.latest_valid_segment_time = 0
|
||||||
self.latest_invalid_segment_time = 0
|
self.latest_invalid_segment_time = 0
|
||||||
self.latest_cache_segment_time = 0
|
self.latest_cache_segment_time = 0
|
||||||
|
self.record_enable_time = datetime.now().astimezone(timezone.utc)
|
||||||
else:
|
else:
|
||||||
self.logger.debug(f"Disabling camera {self.config.name}")
|
self.logger.debug(f"Disabling camera {self.config.name}")
|
||||||
self.stop_all_ffmpeg()
|
self.stop_all_ffmpeg()
|
||||||
|
self.record_enable_time = None
|
||||||
|
|
||||||
# update camera status
|
# update camera status
|
||||||
self.requestor.send_data(
|
self.requestor.send_data(
|
||||||
@ -361,6 +367,12 @@ class CameraWatchdog(threading.Thread):
|
|||||||
if self.config.record.enabled and "record" in p["roles"]:
|
if self.config.record.enabled and "record" in p["roles"]:
|
||||||
now_utc = datetime.now().astimezone(timezone.utc)
|
now_utc = datetime.now().astimezone(timezone.utc)
|
||||||
|
|
||||||
|
# Check if we're within the grace period after enabling recording
|
||||||
|
# Grace period: 90 seconds allows time for ffmpeg to start and create first segment
|
||||||
|
in_grace_period = self.record_enable_time is not None and (
|
||||||
|
now_utc - self.record_enable_time
|
||||||
|
) < timedelta(seconds=90)
|
||||||
|
|
||||||
latest_cache_dt = (
|
latest_cache_dt = (
|
||||||
datetime.fromtimestamp(
|
datetime.fromtimestamp(
|
||||||
self.latest_cache_segment_time, tz=timezone.utc
|
self.latest_cache_segment_time, tz=timezone.utc
|
||||||
@ -386,10 +398,16 @@ class CameraWatchdog(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ensure segments are still being created and that they have valid video data
|
# ensure segments are still being created and that they have valid video data
|
||||||
cache_stale = now_utc > (latest_cache_dt + timedelta(seconds=120))
|
# Skip checks during grace period to allow segments to start being created
|
||||||
valid_stale = now_utc > (latest_valid_dt + timedelta(seconds=120))
|
cache_stale = not in_grace_period and now_utc > (
|
||||||
|
latest_cache_dt + timedelta(seconds=120)
|
||||||
|
)
|
||||||
|
valid_stale = not in_grace_period and now_utc > (
|
||||||
|
latest_valid_dt + timedelta(seconds=120)
|
||||||
|
)
|
||||||
invalid_stale_condition = (
|
invalid_stale_condition = (
|
||||||
self.latest_invalid_segment_time > 0
|
self.latest_invalid_segment_time > 0
|
||||||
|
and not in_grace_period
|
||||||
and now_utc > (latest_invalid_dt + timedelta(seconds=120))
|
and now_utc > (latest_invalid_dt + timedelta(seconds=120))
|
||||||
and self.latest_valid_segment_time
|
and self.latest_valid_segment_time
|
||||||
<= self.latest_invalid_segment_time
|
<= self.latest_invalid_segment_time
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"restart": {
|
"restart": {
|
||||||
"title": "Are you sure you want to restart Frigate?",
|
"title": "Are you sure you want to restart Frigate?",
|
||||||
|
"description": "This will briefly stop Frigate while it restarts.",
|
||||||
"button": "Restart",
|
"button": "Restart",
|
||||||
"restarting": {
|
"restarting": {
|
||||||
"title": "Frigate is Restarting",
|
"title": "Frigate is Restarting",
|
||||||
|
|||||||
@ -19,6 +19,8 @@ import { Button } from "../ui/button";
|
|||||||
import { FaCircleCheck } from "react-icons/fa6";
|
import { FaCircleCheck } from "react-icons/fa6";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import { formatList } from "@/utils/stringUtil";
|
||||||
|
|
||||||
type AnimatedEventCardProps = {
|
type AnimatedEventCardProps = {
|
||||||
event: ReviewSegment;
|
event: ReviewSegment;
|
||||||
@ -50,26 +52,35 @@ export function AnimatedEventCard({
|
|||||||
fetchPreviews: !currentHour,
|
fetchPreviews: !currentHour,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getEventType = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
if (event.data.sub_labels?.includes(text)) return "manual";
|
||||||
|
if (event.data.audio.includes(text)) return "audio";
|
||||||
|
return "object";
|
||||||
|
},
|
||||||
|
[event],
|
||||||
|
);
|
||||||
|
|
||||||
const tooltipText = useMemo(() => {
|
const tooltipText = useMemo(() => {
|
||||||
if (event?.data?.metadata?.title) {
|
if (event?.data?.metadata?.title) {
|
||||||
return event.data.metadata.title;
|
return event.data.metadata.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
`${[
|
`${formatList(
|
||||||
...new Set([
|
[
|
||||||
...(event.data.objects || []),
|
...new Set([
|
||||||
...(event.data.sub_labels || []),
|
...(event.data.objects || []),
|
||||||
...(event.data.audio || []),
|
...(event.data.sub_labels || []),
|
||||||
]),
|
...(event.data.audio || []),
|
||||||
]
|
]),
|
||||||
.filter((item) => item !== undefined && !item.includes("-verified"))
|
]
|
||||||
.map((text) => text.charAt(0).toUpperCase() + text.substring(1))
|
.filter((item) => item !== undefined && !item.includes("-verified"))
|
||||||
.sort()
|
.map((text) => getTranslatedLabel(text, getEventType(text)))
|
||||||
.join(", ")
|
.sort(),
|
||||||
.replaceAll("-verified", "")} ` + t("detected")
|
)} ` + t("detected")
|
||||||
);
|
);
|
||||||
}, [event, t]);
|
}, [event, getEventType, t]);
|
||||||
|
|
||||||
// visibility
|
// visibility
|
||||||
|
|
||||||
|
|||||||
@ -33,13 +33,14 @@ import axios from "axios";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
|
||||||
import { Button, buttonVariants } from "../ui/button";
|
import { Button, buttonVariants } from "../ui/button";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LuCircle } from "react-icons/lu";
|
import { LuCircle } from "react-icons/lu";
|
||||||
import { MdAutoAwesome } from "react-icons/md";
|
import { MdAutoAwesome } from "react-icons/md";
|
||||||
import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip";
|
import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip";
|
||||||
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import { formatList } from "@/utils/stringUtil";
|
||||||
|
|
||||||
type ReviewCardProps = {
|
type ReviewCardProps = {
|
||||||
event: ReviewSegment;
|
event: ReviewSegment;
|
||||||
@ -123,6 +124,12 @@ export default function ReviewCard({
|
|||||||
}
|
}
|
||||||
}, [bypassDialogRef, onDelete]);
|
}, [bypassDialogRef, onDelete]);
|
||||||
|
|
||||||
|
const getEventType = (text: string) => {
|
||||||
|
if (event.data.sub_labels?.includes(text)) return "manual";
|
||||||
|
if (event.data.audio.includes(text)) return "audio";
|
||||||
|
return "object";
|
||||||
|
};
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
className="relative flex w-full cursor-pointer flex-col gap-1.5"
|
className="relative flex w-full cursor-pointer flex-col gap-1.5"
|
||||||
@ -197,20 +204,20 @@ export default function ReviewCard({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="smart-capitalize">
|
<TooltipContent className="smart-capitalize">
|
||||||
{[
|
{formatList(
|
||||||
...new Set([
|
[
|
||||||
...(event.data.objects || []),
|
...new Set([
|
||||||
...(event.data.sub_labels || []),
|
...(event.data.objects || []),
|
||||||
...(event.data.audio || []),
|
...(event.data.sub_labels || []),
|
||||||
]),
|
...(event.data.audio || []),
|
||||||
]
|
]),
|
||||||
.filter(
|
]
|
||||||
(item) => item !== undefined && !item.includes("-verified"),
|
.filter(
|
||||||
)
|
(item) => item !== undefined && !item.includes("-verified"),
|
||||||
.map((text) => capitalizeFirstLetter(text))
|
)
|
||||||
.sort()
|
.map((text) => getTranslatedLabel(text, getEventType(text)))
|
||||||
.join(", ")
|
.sort(),
|
||||||
.replaceAll("-verified", "")}
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<TimeAgo
|
<TimeAgo
|
||||||
|
|||||||
@ -42,12 +42,20 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "../ui/drawer";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../ui/dialog";
|
} from "../ui/dialog";
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
@ -194,6 +202,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
: "max-h-[75dvh] overflow-hidden p-2"
|
: "max-h-[75dvh] overflow-hidden p-2"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{!isDesktop && (
|
||||||
|
<>
|
||||||
|
<DrawerTitle className="sr-only">
|
||||||
|
{t("menu.settings")}
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerDescription className="sr-only">
|
||||||
|
{t("menu.settings")}
|
||||||
|
</DrawerDescription>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
|
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
@ -355,6 +373,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
: "scrollbar-container max-h-[75dvh] w-[92%] overflow-y-scroll rounded-lg md:rounded-2xl"
|
: "scrollbar-container max-h-[75dvh] w-[92%] overflow-y-scroll rounded-lg md:rounded-2xl"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{!isDesktop && (
|
||||||
|
<>
|
||||||
|
<DialogTitle className="sr-only">
|
||||||
|
{t("menu.languages")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{t("menu.languages")}
|
||||||
|
</DialogDescription>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span tabIndex={0} className="sr-only" />
|
<span tabIndex={0} className="sr-only" />
|
||||||
{languages.map(({ code, label }) => (
|
{languages.map(({ code, label }) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@ -395,6 +423,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{!isDesktop && (
|
||||||
|
<>
|
||||||
|
<DialogTitle className="sr-only">
|
||||||
|
{t("menu.darkMode.label")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{t("menu.darkMode.label")}
|
||||||
|
</DialogDescription>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span tabIndex={0} className="sr-only" />
|
<span tabIndex={0} className="sr-only" />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={
|
className={
|
||||||
@ -472,6 +510,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{!isDesktop && (
|
||||||
|
<>
|
||||||
|
<DialogTitle className="sr-only">
|
||||||
|
{t("menu.theme.label")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{t("menu.theme.label")}
|
||||||
|
</DialogDescription>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span tabIndex={0} className="sr-only" />
|
<span tabIndex={0} className="sr-only" />
|
||||||
{colorSchemes.map((scheme) => (
|
{colorSchemes.map((scheme) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
@ -37,6 +38,12 @@ export default function RestartDialog({
|
|||||||
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
|
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
|
||||||
const [countdown, setCountdown] = useState(60);
|
const [countdown, setCountdown] = useState(60);
|
||||||
|
|
||||||
|
const clearBodyPointerEvents = () => {
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.body.style.pointerEvents = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRestartDialogOpen(isOpen);
|
setRestartDialogOpen(isOpen);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
@ -74,14 +81,25 @@ export default function RestartDialog({
|
|||||||
<>
|
<>
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={restartDialogOpen}
|
open={restartDialogOpen}
|
||||||
onOpenChange={() => {
|
onOpenChange={(open) => {
|
||||||
setRestartDialogOpen(false);
|
if (!open) {
|
||||||
onClose();
|
setRestartDialogOpen(false);
|
||||||
|
onClose();
|
||||||
|
clearBodyPointerEvents();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
clearBodyPointerEvents();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{t("restart.title")}</AlertDialogTitle>
|
<AlertDialogTitle>{t("restart.title")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="sr-only">
|
||||||
|
{t("restart.description")}
|
||||||
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
|
|||||||
@ -371,22 +371,23 @@ export default function LivePlayer({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</div>
|
</div>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent className="smart-capitalize">
|
<TooltipContent>
|
||||||
{formatList(
|
{formatList(
|
||||||
[
|
[
|
||||||
...new Set([
|
...new Set(
|
||||||
...(objects || []).map(({ label, sub_label }) =>
|
(objects || [])
|
||||||
label.endsWith("verified")
|
.map(({ label, sub_label }) => {
|
||||||
? sub_label
|
const isManual = label.endsWith("verified");
|
||||||
: label.replaceAll("_", " "),
|
const text = isManual ? sub_label : label;
|
||||||
),
|
const type = isManual ? "manual" : "object";
|
||||||
]),
|
return getTranslatedLabel(text, type);
|
||||||
]
|
})
|
||||||
.filter((label) => label?.includes("-verified") == false)
|
.filter(
|
||||||
.map((label) =>
|
(translated) =>
|
||||||
getTranslatedLabel(label.replace("-verified", "")),
|
translated && !translated.includes("-verified"),
|
||||||
)
|
),
|
||||||
.sort(),
|
),
|
||||||
|
].sort(),
|
||||||
)}
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { FaExclamationTriangle } from "react-icons/fa";
|
import { FaExclamationTriangle } from "react-icons/fa";
|
||||||
import { MdOutlinePersonSearch } from "react-icons/md";
|
import { MdOutlinePersonSearch } from "react-icons/md";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import { formatList } from "@/utils/stringUtil";
|
||||||
|
|
||||||
type PreviewPlayerProps = {
|
type PreviewPlayerProps = {
|
||||||
review: ReviewSegment;
|
review: ReviewSegment;
|
||||||
@ -182,9 +183,8 @@ export default function PreviewThumbnailPlayer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getEventType = (text: string) => {
|
const getEventType = (text: string) => {
|
||||||
if (review.data.objects.includes(text)) return "object";
|
|
||||||
if (review.data.audio.includes(text)) return "audio";
|
|
||||||
if (review.data.sub_labels?.includes(text)) return "manual";
|
if (review.data.sub_labels?.includes(text)) return "manual";
|
||||||
|
if (review.data.audio.includes(text)) return "audio";
|
||||||
return "object";
|
return "object";
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -268,13 +268,16 @@ export default function PreviewThumbnailPlayer({
|
|||||||
className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} bg-gradient-to-br ${review.has_been_reviewed ? "bg-green-600 from-green-600 to-green-700" : "bg-gray-500 from-gray-400 to-gray-500"} z-0`}
|
className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} bg-gradient-to-br ${review.has_been_reviewed ? "bg-green-600 from-green-600 to-green-700" : "bg-gray-500 from-gray-400 to-gray-500"} z-0`}
|
||||||
onClick={() => onClick(review, false, true)}
|
onClick={() => onClick(review, false, true)}
|
||||||
>
|
>
|
||||||
{review.data.objects.sort().map((object) => {
|
{review.data.objects
|
||||||
return getIconForLabel(
|
.sort()
|
||||||
object,
|
.map((object, idx) =>
|
||||||
"object",
|
getIconForLabel(
|
||||||
"size-3 text-white",
|
object,
|
||||||
);
|
"object",
|
||||||
})}
|
"size-3 text-white",
|
||||||
|
`${object}-${idx}`,
|
||||||
|
),
|
||||||
|
)}
|
||||||
{review.data.audio.map((audio) => {
|
{review.data.audio.map((audio) => {
|
||||||
return getIconForLabel(
|
return getIconForLabel(
|
||||||
audio,
|
audio,
|
||||||
@ -288,23 +291,26 @@ export default function PreviewThumbnailPlayer({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</div>
|
</div>
|
||||||
<TooltipContent className="smart-capitalize">
|
<TooltipContent>
|
||||||
{review.data.metadata
|
{review.data.metadata
|
||||||
? review.data.metadata.title
|
? review.data.metadata.title
|
||||||
: [
|
: formatList(
|
||||||
...new Set([
|
[
|
||||||
...(review.data.objects || []),
|
...new Set([
|
||||||
...(review.data.sub_labels || []),
|
...(review.data.objects || []),
|
||||||
...(review.data.audio || []),
|
...(review.data.sub_labels || []),
|
||||||
]),
|
...(review.data.audio || []),
|
||||||
]
|
]),
|
||||||
.filter(
|
]
|
||||||
(item) =>
|
.filter(
|
||||||
item !== undefined && !item.includes("-verified"),
|
(item) =>
|
||||||
)
|
item !== undefined && !item.includes("-verified"),
|
||||||
.map((text) => getTranslatedLabel(text, getEventType(text)))
|
)
|
||||||
.sort()
|
.map((text) =>
|
||||||
.join(", ")}
|
getTranslatedLabel(text, getEventType(text)),
|
||||||
|
)
|
||||||
|
.sort(),
|
||||||
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{!!(
|
{!!(
|
||||||
|
|||||||
@ -26,6 +26,5 @@ export const supportedLanguageKeys = [
|
|||||||
"lt",
|
"lt",
|
||||||
"uk",
|
"uk",
|
||||||
"cs",
|
"cs",
|
||||||
"sk",
|
|
||||||
"hu",
|
"hu",
|
||||||
];
|
];
|
||||||
|
|||||||
@ -62,83 +62,86 @@ export function getIconForLabel(
|
|||||||
label: string,
|
label: string,
|
||||||
type: EventType = "object",
|
type: EventType = "object",
|
||||||
className?: string,
|
className?: string,
|
||||||
|
key?: string,
|
||||||
) {
|
) {
|
||||||
|
const iconKey = key || label;
|
||||||
|
|
||||||
if (label.endsWith("-verified")) {
|
if (label.endsWith("-verified")) {
|
||||||
return getVerifiedIcon(label, className, type);
|
return getVerifiedIcon(label, className, type, iconKey);
|
||||||
} else if (label.endsWith("-plate")) {
|
} else if (label.endsWith("-plate")) {
|
||||||
return getRecognizedPlateIcon(label, className, type);
|
return getRecognizedPlateIcon(label, className, type, iconKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (label) {
|
switch (label) {
|
||||||
// objects
|
// objects
|
||||||
case "bear":
|
case "bear":
|
||||||
return <GiPolarBear key={label} className={className} />;
|
return <GiPolarBear key={iconKey} className={className} />;
|
||||||
case "bicycle":
|
case "bicycle":
|
||||||
return <FaBicycle key={label} className={className} />;
|
return <FaBicycle key={iconKey} className={className} />;
|
||||||
case "bird":
|
case "bird":
|
||||||
return <PiBirdFill key={label} className={className} />;
|
return <PiBirdFill key={iconKey} className={className} />;
|
||||||
case "boat":
|
case "boat":
|
||||||
return <GiSailboat key={label} className={className} />;
|
return <GiSailboat key={iconKey} className={className} />;
|
||||||
case "bus":
|
case "bus":
|
||||||
case "school_bus":
|
case "school_bus":
|
||||||
return <FaBus key={label} className={className} />;
|
return <FaBus key={iconKey} className={className} />;
|
||||||
case "car":
|
case "car":
|
||||||
case "vehicle":
|
case "vehicle":
|
||||||
return <FaCarSide key={label} className={className} />;
|
return <FaCarSide key={iconKey} className={className} />;
|
||||||
case "cat":
|
case "cat":
|
||||||
return <FaCat key={label} className={className} />;
|
return <FaCat key={iconKey} className={className} />;
|
||||||
case "deer":
|
case "deer":
|
||||||
return <GiDeer key={label} className={className} />;
|
return <GiDeer key={iconKey} className={className} />;
|
||||||
case "animal":
|
case "animal":
|
||||||
case "bark":
|
case "bark":
|
||||||
case "dog":
|
case "dog":
|
||||||
return <FaDog key={label} className={className} />;
|
return <FaDog key={iconKey} className={className} />;
|
||||||
case "fox":
|
case "fox":
|
||||||
return <GiFox key={label} className={className} />;
|
return <GiFox key={iconKey} className={className} />;
|
||||||
case "goat":
|
case "goat":
|
||||||
return <GiGoat key={label} className={className} />;
|
return <GiGoat key={iconKey} className={className} />;
|
||||||
case "horse":
|
case "horse":
|
||||||
return <FaHorse key={label} className={className} />;
|
return <FaHorse key={iconKey} className={className} />;
|
||||||
case "kangaroo":
|
case "kangaroo":
|
||||||
return <GiKangaroo key={label} className={className} />;
|
return <GiKangaroo key={iconKey} className={className} />;
|
||||||
case "license_plate":
|
case "license_plate":
|
||||||
return <LuScanBarcode key={label} className={className} />;
|
return <LuScanBarcode key={iconKey} className={className} />;
|
||||||
case "motorcycle":
|
case "motorcycle":
|
||||||
return <FaMotorcycle key={label} className={className} />;
|
return <FaMotorcycle key={iconKey} className={className} />;
|
||||||
case "mouse":
|
case "mouse":
|
||||||
return <FaMouse key={label} className={className} />;
|
return <FaMouse key={iconKey} className={className} />;
|
||||||
case "package":
|
case "package":
|
||||||
return <LuBox key={label} className={className} />;
|
return <LuBox key={iconKey} className={className} />;
|
||||||
case "person":
|
case "person":
|
||||||
return <BsPersonWalking key={label} className={className} />;
|
return <BsPersonWalking key={iconKey} className={className} />;
|
||||||
case "rabbit":
|
case "rabbit":
|
||||||
return <GiRabbit key={label} className={className} />;
|
return <GiRabbit key={iconKey} className={className} />;
|
||||||
case "raccoon":
|
case "raccoon":
|
||||||
return <GiRaccoonHead key={label} className={className} />;
|
return <GiRaccoonHead key={iconKey} className={className} />;
|
||||||
case "robot_lawnmower":
|
case "robot_lawnmower":
|
||||||
return <FaHockeyPuck key={label} className={className} />;
|
return <FaHockeyPuck key={iconKey} className={className} />;
|
||||||
case "sports_ball":
|
case "sports_ball":
|
||||||
return <FaFootballBall key={label} className={className} />;
|
return <FaFootballBall key={iconKey} className={className} />;
|
||||||
case "skunk":
|
case "skunk":
|
||||||
return <GiSquirrel key={label} className={className} />;
|
return <GiSquirrel key={iconKey} className={className} />;
|
||||||
case "squirrel":
|
case "squirrel":
|
||||||
return <LuIcons.LuSquirrel key={label} className={className} />;
|
return <LuIcons.LuSquirrel key={iconKey} className={className} />;
|
||||||
case "umbrella":
|
case "umbrella":
|
||||||
return <FaUmbrella key={label} className={className} />;
|
return <FaUmbrella key={iconKey} className={className} />;
|
||||||
case "waste_bin":
|
case "waste_bin":
|
||||||
return <FaRegTrashAlt key={label} className={className} />;
|
return <FaRegTrashAlt key={iconKey} className={className} />;
|
||||||
// audio
|
// audio
|
||||||
case "crying":
|
case "crying":
|
||||||
case "laughter":
|
case "laughter":
|
||||||
case "scream":
|
case "scream":
|
||||||
case "speech":
|
case "speech":
|
||||||
case "yell":
|
case "yell":
|
||||||
return <MdRecordVoiceOver key={label} className={className} />;
|
return <MdRecordVoiceOver key={iconKey} className={className} />;
|
||||||
case "fire_alarm":
|
case "fire_alarm":
|
||||||
return <FaFire key={label} className={className} />;
|
return <FaFire key={iconKey} className={className} />;
|
||||||
// sub labels
|
// sub labels
|
||||||
case "amazon":
|
case "amazon":
|
||||||
return <FaAmazon key={label} className={className} />;
|
return <FaAmazon key={iconKey} className={className} />;
|
||||||
case "an_post":
|
case "an_post":
|
||||||
case "canada_post":
|
case "canada_post":
|
||||||
case "dpd":
|
case "dpd":
|
||||||
@ -148,20 +151,20 @@ export function getIconForLabel(
|
|||||||
case "postnord":
|
case "postnord":
|
||||||
case "purolator":
|
case "purolator":
|
||||||
case "royal_mail":
|
case "royal_mail":
|
||||||
return <GiPostStamp key={label} className={className} />;
|
return <GiPostStamp key={iconKey} className={className} />;
|
||||||
case "dhl":
|
case "dhl":
|
||||||
return <FaDhl key={label} className={className} />;
|
return <FaDhl key={iconKey} className={className} />;
|
||||||
case "fedex":
|
case "fedex":
|
||||||
return <FaFedex key={label} className={className} />;
|
return <FaFedex key={iconKey} className={className} />;
|
||||||
case "ups":
|
case "ups":
|
||||||
return <FaUps key={label} className={className} />;
|
return <FaUps key={iconKey} className={className} />;
|
||||||
case "usps":
|
case "usps":
|
||||||
return <FaUsps key={label} className={className} />;
|
return <FaUsps key={iconKey} className={className} />;
|
||||||
default:
|
default:
|
||||||
if (type === "audio") {
|
if (type === "audio") {
|
||||||
return <GiSoundWaves key={label} className={className} />;
|
return <GiSoundWaves key={iconKey} className={className} />;
|
||||||
}
|
}
|
||||||
return <LuLassoSelect key={label} className={className} />;
|
return <LuLassoSelect key={iconKey} className={className} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,11 +172,12 @@ function getVerifiedIcon(
|
|||||||
label: string,
|
label: string,
|
||||||
className?: string,
|
className?: string,
|
||||||
type: EventType = "object",
|
type: EventType = "object",
|
||||||
|
key?: string,
|
||||||
) {
|
) {
|
||||||
const simpleLabel = label.substring(0, label.lastIndexOf("-"));
|
const simpleLabel = label.substring(0, label.lastIndexOf("-"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={label} className="relative flex items-center">
|
<div key={key} className="relative flex items-center">
|
||||||
{getIconForLabel(simpleLabel, type, className)}
|
{getIconForLabel(simpleLabel, type, className)}
|
||||||
<FaCheckCircle className="absolute -bottom-0.5 -right-0.5 size-2" />
|
<FaCheckCircle className="absolute -bottom-0.5 -right-0.5 size-2" />
|
||||||
</div>
|
</div>
|
||||||
@ -184,11 +188,12 @@ function getRecognizedPlateIcon(
|
|||||||
label: string,
|
label: string,
|
||||||
className?: string,
|
className?: string,
|
||||||
type: EventType = "object",
|
type: EventType = "object",
|
||||||
|
key?: string,
|
||||||
) {
|
) {
|
||||||
const simpleLabel = label.substring(0, label.lastIndexOf("-"));
|
const simpleLabel = label.substring(0, label.lastIndexOf("-"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={label} className="relative inline-flex items-center">
|
<div key={key} className="relative inline-flex items-center">
|
||||||
{getIconForLabel(simpleLabel, type, className)}
|
{getIconForLabel(simpleLabel, type, className)}
|
||||||
<LuScanBarcode className="absolute -bottom-0.5 -right-0.5 size-2" />
|
<LuScanBarcode className="absolute -bottom-0.5 -right-0.5 size-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -33,7 +33,12 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import {
|
||||||
|
isDesktop,
|
||||||
|
isMobile,
|
||||||
|
isMobileOnly,
|
||||||
|
isTablet,
|
||||||
|
} from "react-device-detect";
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
@ -738,7 +743,7 @@ export function RecordingView({
|
|||||||
aspectRatio: getCameraAspect(mainCamera),
|
aspectRatio: getCameraAspect(mainCamera),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isDesktop && (
|
{(isDesktop || isTablet) && (
|
||||||
<GenAISummaryDialog
|
<GenAISummaryDialog
|
||||||
review={activeReviewItem}
|
review={activeReviewItem}
|
||||||
onOpen={onAnalysisOpen}
|
onOpen={onAnalysisOpen}
|
||||||
@ -1001,7 +1006,7 @@ function Timeline({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isMobile && timelineType == "timeline" && (
|
{isMobileOnly && timelineType == "timeline" && (
|
||||||
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen}>
|
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen}>
|
||||||
<GenAISummaryChip review={activeReviewItem} />
|
<GenAISummaryChip review={activeReviewItem} />
|
||||||
</GenAISummaryDialog>
|
</GenAISummaryDialog>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user