Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
Logan Garrett 2025-03-17 22:27:08 -04:00
commit aa1f1dfab8
103 changed files with 2768 additions and 1071 deletions

View File

@ -4,6 +4,8 @@
# Frigate - NVR With Realtime Object Detection for IP Cameras
\[English\] | [简体中文](https://github.com/blakeblackshear/frigate/README_CN.md)
A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras.
Use of a [Google Coral Accelerator](https://coral.ai/products/) is optional, but highly recommended. The Coral will outperform even the best CPUs and can process 100+ FPS with very little overhead.

52
README_CN.md Normal file
View File

@ -0,0 +1,52 @@
<p align="center">
<img align="center" alt="logo" src="docs/static/img/frigate.png">
</p>
# Frigate - 一个具有实时目标检测的本地NVR
[English](https://github.com/blakeblackshear/frigate) | \[简体中文\]
一个完整的本地网络视频录像机NVR专为[Home Assistant](https://www.home-assistant.io)设计具备AI物体检测功能。使用OpenCV和TensorFlow在本地为IP摄像头执行实时物体检测。
强烈推荐使用可选配件:[Google Coral加速器](https://coral.ai/products/)。在该场景下Coral的性能甚至超过目前的顶级CPU并且可以以极低的电力开销轻松处理100 以上的画面帧。
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与Home Assistant紧密集成
- 设计上通过仅在必要时和必要地点寻找物体,最大限度地减少资源使用并最大化性能
- 大量利用多进程处理,强调实时性而非处理每一帧
- 使用非常低开销的运动检测来确定运行物体检测的位置
- 使用TensorFlow进行物体检测运行在单独的进程中以达到最大FPS
- 通过MQTT进行通信便于集成到其他系统中
- 根据检测到的物体设置保留时间进行视频录制
- 24/7全天候录制
- 通过RTSP重新流传输以减少摄像头的连接数
- 支持WebRTC和MSE实现低延迟的实时观看
## 文档(英文)
你可以在这里查看文档 https://docs.frigate.video
## 赞助
如果您想通过捐赠支持开发,请使用 [Github Sponsors](https://github.com/sponsors/blakeblackshear)。
## 截图
### 实时监控面板
<div>
<img width="800" alt="实时监控面板" src="https://github.com/blakeblackshear/frigate/assets/569905/5e713cb9-9db5-41dc-947a-6937c3bc376e">
</div>
### 简单的审查工作流程
<div>
<img width="800" alt="简单的审查工作流程" src="https://github.com/blakeblackshear/frigate/assets/569905/6fed96e8-3b18-40e5-9ddc-31e6f3c9f2ff">
</div>
### 多摄像头可按时间轴查看
<div>
<img width="800" alt="多摄像头可按时间轴查看" src="https://github.com/blakeblackshear/frigate/assets/569905/d6788a15-0eeb-4427-a8d4-80b93cae3d74">
</div>
### 内置遮罩和区域编辑器
<div>
<img width="800" alt="内置遮罩和区域编辑器" src="https://github.com/blakeblackshear/frigate/assets/569905/d7885fc3-bfe6-452f-b7d0-d957cb3e31f5">
</div>

View File

@ -79,6 +79,11 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.
-keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null
fi
# build templates for optional FRIGATE_BASE_PATH environment variable
python3 /usr/local/nginx/get_base_path.py | \
tempio -template /usr/local/nginx/templates/base_path.gotmpl \
-out /usr/local/nginx/conf/base_path.conf
# build templates for optional TLS support
python3 /usr/local/nginx/get_tls_settings.py | \
tempio -template /usr/local/nginx/templates/listen.gotmpl \

View File

@ -96,6 +96,7 @@ http {
gzip_types application/vnd.apple.mpegurl;
include auth_location.conf;
include base_path.conf;
location /vod/ {
include auth_request.conf;
@ -299,6 +300,18 @@ http {
add_header Cache-Control "public";
}
location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ {
access_log off;
expires 1y;
add_header Cache-Control "public";
default_type application/json;
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types application/json;
sub_filter '"start_url": "/"' '"start_url" : "$http_x_ingress_path"';
sub_filter '"src": "/' '"src": "$http_x_ingress_path/';
}
sub_filter 'href="/BASE_PATH/' 'href="$http_x_ingress_path/';
sub_filter 'url(/BASE_PATH/' 'url($http_x_ingress_path/';
sub_filter '"/BASE_PATH/dist/' '"$http_x_ingress_path/dist/';

View File

@ -0,0 +1,10 @@
"""Prints the base path as json to stdout."""
import json
import os
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
result: dict[str, any] = {"base_path": base_path}
print(json.dumps(result))

View File

@ -0,0 +1,19 @@
{{ if .base_path }}
location = {{ .base_path }} {
return 302 {{ .base_path }}/;
}
location ^~ {{ .base_path }}/ {
# remove base_url from the path before passing upstream
rewrite ^{{ .base_path }}/(.*) /$1 break;
proxy_pass $scheme://127.0.0.1:8971;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Ingress-Path {{ .base_path }};
access_log off;
}
{{ end }}

View File

@ -172,6 +172,38 @@ listen [::]:8971 ipv6only=off ssl;
listen [::]:5000 ipv6only=off;
```
## Base path
By default, Frigate runs at the root path (`/`). However some setups require to run Frigate under a custom path prefix (e.g. `/frigate`), especially when Frigate is located behind a reverse proxy that requires path-based routing.
### Set Base Path via HTTP Header
The preferred way to configure the base path is through the `X-Ingress-Path` HTTP header, which needs to be set to the desired base path in an upstream reverse proxy.
For example, in Nginx:
```
location /frigate {
proxy_set_header X-Ingress-Path /frigate;
proxy_pass http://frigate_backend;
}
```
### Set Base Path via Environment Variable
When it is not feasible to set the base path via a HTTP header, it can also be set via the `FRIGATE_BASE_PATH` environment variable in the Docker Compose file.
For example:
```
services:
frigate:
image: blakeblackshear/frigate:latest
environment:
- FRIGATE_BASE_PATH=/frigate
```
This can be used for example to access Frigate via a Tailscale agent (https), by simply forwarding all requests to the base path (http):
```
tailscale serve --https=443 --bg --set-path /frigate http://localhost:5000/frigate
```
## Custom Dependencies
### Custom ffmpeg build

View File

@ -22,3 +22,13 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
### Can I keep using my Frigate+ models even if I do not renew my subscription?
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models.
### Why can't I submit images to Frigate+?
If you've configured your API key and the Frigate+ Settings page in the UI shows that the key is active, you need to ensure that you've enabled both snapshots and `clean_copy` snapshots for the cameras you'd like to submit images for. Note that `clean_copy` is enabled by default when snapshots are enabled.
```yaml
snapshots:
enabled: true
clean_copy: true
```

View File

@ -9,6 +9,7 @@ import traceback
from datetime import datetime, timedelta
from functools import reduce
from io import StringIO
from pathlib import Path as FilePath
from typing import Any, Optional
import aiofiles
@ -174,6 +175,18 @@ def config(request: Request):
config["model"]["all_attributes"] = config_obj.model.all_attributes
config["model"]["non_logo_attributes"] = config_obj.model.non_logo_attributes
# Add model plus data if plus is enabled
if config["plus"]["enabled"]:
model_json_path = FilePath(config["model"]["path"]).with_suffix(".json")
try:
with open(model_json_path, "r") as f:
model_plus_data = json.load(f)
config["model"]["plus"] = model_plus_data
except FileNotFoundError:
config["model"]["plus"] = None
except json.JSONDecodeError:
config["model"]["plus"] = None
# use merged labelamp
for detector_config in config["detectors"].values():
detector_config["model"]["labelmap"] = (

View File

@ -6,6 +6,7 @@ import random
import shutil
import string
import cv2
from fastapi import APIRouter, Depends, Request, UploadFile
from fastapi.responses import JSONResponse
from pathvalidate import sanitize_filename
@ -14,9 +15,11 @@ from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
from frigate.config.camera import DetectConfig
from frigate.const import FACE_DIR
from frigate.embeddings import EmbeddingsContext
from frigate.models import Event
from frigate.util.path import get_event_snapshot
logger = logging.getLogger(__name__)
@ -87,16 +90,27 @@ def train_face(request: Request, name: str, body: dict = None):
)
json: dict[str, any] = body or {}
training_file = os.path.join(
FACE_DIR, f"train/{sanitize_filename(json.get('training_file', ''))}"
)
training_file_name = sanitize_filename(json.get("training_file", ""))
training_file = os.path.join(FACE_DIR, f"train/{training_file_name}")
event_id = json.get("event_id")
if not training_file or not os.path.isfile(training_file):
if not training_file_name and not event_id:
return JSONResponse(
content=(
{
"success": False,
"message": f"Invalid filename or no file exists: {training_file}",
"message": "A training file or event_id must be passed.",
}
),
status_code=400,
)
if training_file_name and not os.path.isfile(training_file):
return JSONResponse(
content=(
{
"success": False,
"message": f"Invalid filename or no file exists: {training_file_name}",
}
),
status_code=404,
@ -106,7 +120,36 @@ def train_face(request: Request, name: str, body: dict = None):
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
new_name = f"{sanitized_name}-{rand_id}.webp"
new_file = os.path.join(FACE_DIR, f"{sanitized_name}/{new_name}")
shutil.move(training_file, new_file)
if training_file_name:
shutil.move(training_file, new_file)
else:
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
return JSONResponse(
content=(
{
"success": False,
"message": f"Invalid event_id or no event exists: {event_id}",
}
),
status_code=404,
)
snapshot = get_event_snapshot(event)
face_box = event.data["attributes"][0]["box"]
detect_config: DetectConfig = request.app.frigate_config.cameras[
event.camera
].detect
# crop onto the face box minus the bounding box itself
x1 = int(face_box[0] * detect_config.width) + 2
y1 = int(face_box[1] * detect_config.height) + 2
x2 = x1 + int(face_box[2] * detect_config.width) - 4
y2 = y1 + int(face_box[3] * detect_config.height) - 4
face = snapshot[y1:y2, x1:x2]
cv2.imwrite(new_file, face)
context: EmbeddingsContext = request.app.embeddings
context.clear_face_classifier()
@ -115,7 +158,7 @@ def train_face(request: Request, name: str, body: dict = None):
content=(
{
"success": True,
"message": f"Successfully saved {training_file} as {new_name}.",
"message": f"Successfully saved {training_file_name} as {new_name}.",
}
),
status_code=200,

View File

@ -701,6 +701,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
for k, v in event["data"].items()
if k
in [
"attributes",
"type",
"score",
"top_score",

View File

@ -27,6 +27,8 @@ from .api import RealTimeProcessorApi
logger = logging.getLogger(__name__)
MAX_DETECTION_HEIGHT = 1080
MAX_FACE_ATTEMPTS = 100
MIN_MATCHING_FACES = 2
@ -88,7 +90,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
os.path.join(MODEL_CACHE_DIR, "facedet/facedet.onnx"),
config="",
input_size=(320, 320),
score_threshold=self.face_config.detection_threshold,
score_threshold=0.5,
nms_threshold=0.3,
)
self.landmark_detector = cv2.face.createFacemarkLBF()
@ -212,11 +214,23 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
self.face_recognizer = None
self.label_map = {}
def __detect_face(self, input: np.ndarray) -> tuple[int, int, int, int]:
def __detect_face(
self, input: np.ndarray, threshold: float
) -> tuple[int, int, int, int]:
"""Detect faces in input image."""
if not self.face_detector:
return None
# YN face detector fails at extreme definitions
# this rescales to a size that can properly detect faces
# still retaining plenty of detail
if input.shape[0] > MAX_DETECTION_HEIGHT:
scale_factor = MAX_DETECTION_HEIGHT / input.shape[0]
new_width = int(scale_factor * input.shape[1])
input = cv2.resize(input, (new_width, MAX_DETECTION_HEIGHT))
else:
scale_factor = 1
self.face_detector.setInputSize((input.shape[1], input.shape[0]))
faces = self.face_detector.detect(input)
@ -226,11 +240,14 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
face = None
for _, potential_face in enumerate(faces[1]):
if potential_face[-1] < threshold:
continue
raw_bbox = potential_face[0:4].astype(np.uint16)
x: int = max(raw_bbox[0], 0)
y: int = max(raw_bbox[1], 0)
w: int = raw_bbox[2]
h: int = raw_bbox[3]
x: int = int(max(raw_bbox[0], 0) / scale_factor)
y: int = int(max(raw_bbox[1], 0) / scale_factor)
w: int = int(raw_bbox[2] / scale_factor)
h: int = int(raw_bbox[3] / scale_factor)
bbox = (x, y, x + w, y + h)
if face is None or area(bbox) > area(face):
@ -300,7 +317,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
left, top, right, bottom = person_box
person = rgb[top:bottom, left:right]
face_box = self.__detect_face(person)
face_box = self.__detect_face(person, self.face_config.detection_threshold)
if not face_box:
logger.debug("Detected no faces for person object.")
@ -406,7 +423,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
),
cv2.IMREAD_COLOR,
)
face_box = self.__detect_face(img)
# detect faces with lower confidence since we expect the face
# to be visible in uploaded images
face_box = self.__detect_face(img, 0.5)
if not face_box:
return {
@ -463,6 +483,16 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
)
shutil.move(current_file, new_file)
files = sorted(
os.listdir(folder),
key=lambda f: os.path.getctime(os.path.join(folder, f)),
reverse=True,
)
# delete oldest face image if maximum is reached
if len(files) > MAX_FACE_ATTEMPTS:
os.unlink(os.path.join(folder, files[-1]))
def expire_object(self, object_id: str):
if object_id in self.detected_faces:
self.detected_faces.pop(object_id)

View File

@ -4,6 +4,9 @@ import base64
import os
from pathlib import Path
import cv2
from numpy import ndarray
from frigate.const import CLIPS_DIR, THUMB_DIR
from frigate.models import Event
@ -21,6 +24,11 @@ def get_event_thumbnail_bytes(event: Event) -> bytes | None:
return None
def get_event_snapshot(event: Event) -> ndarray:
media_name = f"{event.camera}-{event.id}"
return cv2.imread(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
### Deletion

View File

@ -152,7 +152,6 @@
"hi_hat": "Hi-Hat",
"wood_block": "Wood Block",
"tambourine": "Tambourine",
"rattle": "Rattle",
"maraca": "Maraca",
"gong": "Gong",
"tubular_bells": "Tubular Bells",
@ -282,7 +281,6 @@
"ship": "Ship",
"motor_vehicle": "Motor Vehicle",
"car": "Car",
"honk": "Honk",
"toot": "Toot",
"car_alarm": "Car Alarm",
"power_windows": "Power Windows",
@ -426,5 +424,6 @@
"pink_noise": "Pink Noise",
"television": "Television",
"radio": "Radio",
"field_recording": "Field Recording"
"field_recording": "Field Recording",
"scream": "Scream"
}

View File

@ -34,14 +34,22 @@
"minute": "{{time}} minutes",
"s": "s",
"second": "{{time}} seconds",
"formattedTimestamp": "%b %-d, %I:%M:%S %p",
"formattedTimestamp.24hour": "%b %-d, %H:%M:%S",
"formattedTimestamp2": "%m/%d %I:%M:%S%P",
"formattedTimestamp2.24hour": "%d %b %H:%M:%S",
"formattedTimestampExcludeSeconds": "%b %-d, %I:%M %p",
"formattedTimestampExcludeSeconds.24hour": "%b %-d, %H:%M",
"formattedTimestampWithYear": "%b %-d %Y, %I:%M %p",
"formattedTimestampWithYear.24hour": "%b %-d %Y, %H:%M",
"formattedTimestamp": {
"12hour": "%b %-d, %I:%M:%S %p",
"24hour": "%b %-d, %H:%M:%S"
},
"formattedTimestamp2": {
"12hour": "%m/%d %I:%M:%S%P",
"24hour": "%d %b %H:%M:%S"
},
"formattedTimestampExcludeSeconds": {
"12hour": "%b %-d, %I:%M %p",
"24hour": "%b %-d, %H:%M"
},
"formattedTimestampWithYear": {
"12hour": "%b %-d %Y, %I:%M %p",
"24hour": "%b %-d %Y, %H:%M"
},
"formattedTimestampOnlyMonthAndDay": "%b %-d"
},
"unit": {
@ -56,6 +64,7 @@
"button": {
"apply": "Apply",
"reset": "Reset",
"done": "Done",
"enabled": "Enabled",
"enable": "Enable",
"disabled": "Disabled",
@ -70,6 +79,8 @@
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit Fullscreen",
"pictureInPicture": "Picture in Picture",
"twoWayTalk": "Two Way Talk",
"cameraAudio": "Camera Audio",
"on": "ON",
"off": "OFF",
"edit": "Edit",
@ -84,7 +95,8 @@
"play": "Play",
"unselect": "Unselect",
"export": "Export",
"deleteNow": "Delete Now"
"deleteNow": "Delete Now",
"next": "Next"
},
"menu": {
"system": "System",
@ -97,14 +109,18 @@
"language": {
"en": "English",
"zhCN": "简体中文 (Simplified Chinese)",
"withSystem.label": "Use the system settings for language"
"withSystem": {
"label": "Use the system settings for language"
}
},
"appearance": "Appearance",
"darkMode": {
"label": "Dark Mode",
"light": "Light",
"dark": "Dark",
"withSystem.label": "Use the system settings for light or dark mode"
"withSystem": {
"label": "Use the system settings for light or dark mode"
}
},
"withSystem": "System",
"theme": {
@ -117,20 +133,27 @@
"default": "Default"
},
"help": "Help",
"documentation.label": "Frigate documentation",
"documentation": "Documentation",
"documentation": {
"title": "Documentation",
"label": "Frigate documentation"
},
"restart": "Restart Frigate",
"live": "Live",
"live.allCameras": "All Cameras",
"live.cameras": "Cameras",
"live.cameras.count_one": "{{count}} Camera",
"live.cameras.count_other": "{{count}} Cameras",
"live": {
"title": "Live",
"allCameras": "All Cameras",
"cameras": {
"title": "Cameras",
"count_one": "{{count}} Camera",
"count_other": "{{count}} Cameras"
}
},
"review": "Review",
"explore": "Explore",
"export": "Export",
"uiPlayground": "UI Playground",
"faceLibrary": "Face Library",
"user": {
"title": "User",
"account": "Account",
"current": "Current User: {{user}}",
"anonymous": "anonymous",
@ -141,8 +164,11 @@
"toast": {
"copyUrlToClipboard": "Copied URL to clipboard.",
"save": {
"error": "Failed to save config changes: {{errorMessage}}",
"error.noMessage": "Failed to save config changes"
"title": "Save",
"error": {
"title": "Failed to save config changes: {{errorMessage}}",
"noMessage": "Failed to save config changes"
}
}
},
"role": {
@ -153,10 +179,14 @@
},
"pagination": {
"label": "pagination",
"previous": "Previous",
"previous.label": "Go to previous page",
"next": "Next",
"next.label": "Go to next page",
"previous": {
"title": "Previous",
"label": "Go to previous page"
},
"next": {
"title": "Next",
"label": "Go to next page"
},
"more": "More pages"
},
"accessDenied": {

View File

@ -5,13 +5,15 @@
"edit": "Edit Camera Group",
"delete": {
"label": "Delete Camera Group",
"confirm": "Confirm Delete",
"confirm.desc": "Are you sure you want to delete the camera group <em>{{name}}</em>?"
"confirm": {
"title": "Confirm Delete",
"desc": "Are you sure you want to delete the camera group <em>{{name}}</em>?"
}
},
"name": {
"label": "Name",
"placeholder": "Enter a name...",
"error": {
"errorMessage": {
"mustLeastCharacters": "Camera group name must be at least 2 characters.",
"exists": "Camera group name already exists.",
"nameMustNotPeriod": "Camera group name must not contain a period.",
@ -30,10 +32,12 @@
"title": "{{cameraName}} Streaming Settings",
"desc": "Change the live streaming options for this camera group's dashboard. <em>These settings are device/browser-specific.</em>",
"audioIsAvailable": "Audio is available for this stream",
"audioIsUnavailable": "Audio is available for this stream",
"audioIsUnavailable": "Audio is unavailable for this stream",
"audio": {
"tips": "Audio must be output from your camera and configured in go2rtc for this stream.",
"tips.document": "Read the documentation "
"tips": {
"title": "Audio must be output from your camera and configured in go2rtc for this stream.",
"document": "Read the documentation "
}
},
"streamMethod": {
"label": "Streaming Method",
@ -48,8 +52,10 @@
},
"continuousStreaming": {
"label": "Continuous Streaming",
"desc": "Camera image will always be a live stream when visible on the dashboard, even if no activity is being detected.",
"desc.warning": "Continuous streaming may cause high bandwidth usage and performance issues. Use with caution."
"desc": {
"title": "Camera image will always be a live stream when visible on the dashboard, even if no activity is being detected.",
"warning": "Continuous streaming may cause high bandwidth usage and performance issues. Use with caution."
}
}
}
},

View File

@ -15,13 +15,19 @@
"desc": "Objects in locations you want to avoid are not false positives. Submitting them as false positives will confuse the model."
},
"review": {
"true.label": "Confirm this label for Frigate Plus",
"true_one": "This is a {{label}}",
"true_other": "This is an {{label}}",
"false_one": "This is not a {{label}}",
"false_other": "This is not an {{label}}",
"false.label": "Do not confirm this label for Frigate Plus",
"state.submitted": "Submitted"
"true": {
"label": "Confirm this label for Frigate Plus",
"true_one": "This is a {{label}}",
"true_other": "This is an {{label}}"
},
"false": {
"label": "Do not confirm this label for Frigate Plus",
"false_one": "This is not a {{label}}",
"false_other": "This is not an {{label}}"
},
"state": {
"submitted": "Submitted"
}
}
},
"video": {
@ -34,10 +40,14 @@
"lastHour_one": "Last Hour",
"lastHour_other": "Last {{count}} Hours",
"custom": "Custom",
"start": "Start Time",
"start.label": "Select Start Time",
"end": "End Time",
"end.label": "Select End Time"
"start": {
"title": "Start Time",
"label": "Select Start Time"
},
"end": {
"title": "End Time",
"label": "Select End Time"
}
},
"name": {
"placeholder": "Name the Export"
@ -61,9 +71,11 @@
"streaming": {
"label": "Stream",
"restreaming": {
"NotEnabled": "Restreaming is not enabled for this camera.",
"desc": "Set up go2rtc for additional live view options and audio for this camera.",
"desc.readTheDocumentation": "Read the documentation "
"disabled": "Restreaming is not enabled for this camera.",
"desc": {
"title": "Set up go2rtc for additional live view options and audio for this camera.",
"readTheDocumentation": "Read the documentation "
}
},
"showStats": {
"label": "Show stream stats",
@ -78,14 +90,19 @@
"placeholder": "Enter a name for your search",
"overwrite": "{{searchName}} already exists. Saving will overwrite the existing value.",
"success": "Search ({{searchName}}) has been saved.",
"button.save.label": "Save this search"
"button": {
"save": {
"label": "Save this search"
}
}
}
},
"recording": {
"confirmDelete": {
"title": "Confirm Delete",
"desc": "Are you sure you want to delete all recorded video associated with this review item?<br /><br />Hold the <em>Shift</em> key to bypass this dialog in the future.",
"desc.selected": "Are you sure you want to delete all recorded video associated with this review item?<br /><br />Hold the <em>Shift</em> key to bypass this dialog in the future."
"desc": {
"selected": "Are you sure you want to delete all recorded video associated with this review item?<br /><br />Hold the <em>Shift</em> key to bypass this dialog in the future."
}
},
"button": {
"export": "Export",

View File

@ -2,22 +2,30 @@
"filter": "Filter",
"labels": {
"label": "Labels",
"all": "All Labels",
"all.short": "Labels",
"all": {
"title": "All Labels",
"short": "Labels"
},
"count": "{{count}} Labels"
},
"zones": {
"all": "All Zones",
"all.short": "Zones"
"label": "Zones",
"all": {
"title": "All Zones",
"short": "Zones"
}
},
"dates": {
"all": "All Dates",
"all.short": "Dates"
"all": {
"title": "All Dates",
"short": "Dates"
}
},
"more": "More Filters",
"reset.label": "Reset filters to default values",
"reset": {
"label": "Reset filters to default values"
},
"timeRange": "Time Range",
"zones.label": "Zones",
"subLabels": {
"label": "Sub Labels",
"all": "All Sub Labels"
@ -45,8 +53,10 @@
},
"cameras": {
"label": "Cameras Filter",
"all": "All Cameras",
"all.short": "Cameras"
"all": {
"title": "All Cameras",
"short": "Cameras"
}
},
"review": {
"showReviewed": "Show Reviewed"
@ -57,12 +67,16 @@
"explore": {
"settings": {
"title": "Settings",
"defaultView": "Default View",
"defaultView.desc": "When no filters are selected, display a summary of the most recent tracked objects per label, or display an unfiltered grid.",
"defaultView.summary": "Summary",
"defaultView.unfilteredGrid": "Unfiltered Grid",
"gridColumns": "Grid Columns",
"gridColumns.desc": "Select the number of columns in the grid view.",
"defaultView": {
"title": "Default View",
"desc": "When no filters are selected, display a summary of the most recent tracked objects per label, or display an unfiltered grid.",
"summary": "Summary",
"unfilteredGrid": "Unfiltered Grid"
},
"gridColumns": {
"title": "Grid Columns",
"desc": "Select the number of columns in the grid view."
},
"searchSource": {
"label": "Search Source",
"desc": "Choose whether to search the thumbnails or descriptions of your tracked objects.",
@ -81,8 +95,10 @@
"logSettings": {
"label": "Filter log level",
"filterBySeverity": "Filter logs by severity",
"loading": "Loading",
"loading.desc": "When the log pane is scrolled to the bottom, new logs automatically stream as they are added.",
"loading": {
"title": "Loading",
"desc": "When the log pane is scrolled to the bottom, new logs automatically stream as they are added."
},
"disableLogStreaming": "Disable log streaming",
"allLogs": "All logs"
},

View File

@ -13,18 +13,30 @@
},
"cameraDisabled": "Camera is disabled",
"stats": {
"streamType": "Stream Type:",
"streamType.short": "Type",
"bandwidth": "Bandwidth:",
"bandwidth.short": "Bandwidth",
"latency": "Latency:",
"latency.short": "Latency",
"latency.value": "{{seconds}} seconds",
"latency.short.value": "{{seconds}} sec",
"streamType": {
"title": "Stream Type:",
"short": "Type"
},
"bandwidth": {
"title": "Bandwidth:",
"short": "Bandwidth"
},
"latency": {
"title": "Latency:",
"value": "{{seconds}} seconds",
"short": {
"title": "Latency",
"value": "{{seconds}} sec"
}
},
"totalFrames": "Total Frames:",
"droppedFrames": "Dropped Frames:",
"droppedFrames.short": "Dropped",
"droppedFrames.short.value": "{{droppedFrames}} frames",
"droppedFrames": {
"title": "Dropped Frames:",
"short": {
"title": "Dropped",
"value": "{{droppedFrames}} frames"
}
},
"decodedFrames": "Decoded Frames:",
"droppedFrameRate": "Dropped Frame Rate:"
},

View File

@ -58,8 +58,10 @@
},
"annotationSettings": {
"title": "Annotation Settings",
"showAllZones": "Show All Zones",
"showAllZones.desc": "Always show zones on frames where objects have entered a zone.",
"showAllZones": {
"title": "Show All Zones",
"desc": "Always show zones on frames where objects have entered a zone."
},
"offset": {
"label": "Annotation Offset",
"desc": "This data comes from your camera's detect feed but is overlayed on images from the the record feed. It is unlikely that the two streams are perfectly in sync. As a result, the bounding box and the footage will not line up perfectly. However, the <code>annotation_offset</code> field can be used to adjust this.",
@ -96,29 +98,34 @@
"updatedSublabelFailed": "Failed to update sub label: {{errorMessage}}"
}
}
},
"label": "Label",
"editSubLable": "Edit sub label",
"editSubLable.desc": "Enter a new sub label for this {{label}}",
"editSubLable.desc.noLabel": "Enter a new sub label for this tracked object",
"topScore": "Top Score",
"topScore.info": "The top score is the highest median score for the tracked object, so this may differ from the score shown on the search result thumbnail.",
"editSubLabel": {
"title": "Edit sub label",
"desc": "Enter a new sub label for this {{label}}",
"descNoLabel": "Enter a new sub label for this tracked object"
},
"topScore": {
"label": "Top Score",
"info": "The top score is the highest median score for the tracked object, so this may differ from the score shown on the search result thumbnail."
},
"estimatedSpeed": "Estimated Speed",
"objects": "Objects",
"camera": "Camera",
"zones": "Zones",
"timestamp": "Timestamp",
"button": {
"findSimilar": "Find Similar"
"findSimilar": "Find Similar",
"regenerate": {
"title": "Regenerate",
"label": "Regenerate tracked object description"
}
},
"description": {
"label": "Description",
"placeholder": "Description of the tracked object",
"aiTips": "Frigate will not request a description from your Generative AI provider until the tracked object's lifecycle has ended."
},
"button.regenerate": "Regenerate",
"button.regenerate.label": "Regenerate tracked object description",
"expandRegenerationMenu": "Expand regeneration menu",
"regenerateFromSnapshot": "Regenerate from Snapshot",
"regenerateFromThumbnails": "Regenerate from Thumbnails",

View File

@ -1,41 +1,45 @@
{
"documentTitle": "Face Library - Frigate",
"uploadFaceImage": {
"title": "Upload Face Image",
"desc": "Upload an image to scan for faces and include for {{pageToggle}}"
"description": {
"addFace": "Walk through adding a new face to the Face Library."
},
"documentTitle": "Face Library - Frigate",
"uploadFaceImage": {
"title": "Upload Face Image",
"desc": "Upload an image to scan for faces and include for {{pageToggle}}"
},
"createFaceLibrary": {
"title": "Create Face Library",
"desc": "Create a new face library",
"nextSteps": "It is recommended to use the Train tab to select and train images for each person as they are detected. When building a strong foundation it is strongly recommended to only train on images that are straight-on. Ignore images from cameras that recognize faces from an angle."
},
"train": {
"title": "Train",
"aria": "Select train"
},
"selectItem": "Select {{item}}",
"button": {
"deleteFaceAttempts": "Delete Face Attempts",
"addFace": "Add Face",
"uploadImage": "Upload Image",
"reprocessFace": "Reprocess Face"
},
"readTheDocs": "Read the documentation to view more details on refining images for the Face Library",
"trainFaceAs": "Train Face as:",
"trainFace": "Train Face",
"toast": {
"success": {
"uploadedImage": "Successfully uploaded image.",
"addFaceLibrary": "{{name}} has successfully been added to the Face Library!",
"deletedFace": "Successfully deleted face.",
"trainedFace": "Successfully trained face.",
"updatedFaceScore": "Successfully updated face score."
},
"createFaceLibrary": {
"title": "Create Face Library",
"desc": "Create a new face library"
},
"train": {
"title": "Train",
"aria": "Select train"
},
"selectItem": "Select {{item}}",
"button": {
"deleteFaceAttempts": "Delete Face Attempts",
"addFace": "Add Face",
"uploadImage": "Upload Image",
"reprocessFace:": "Reprocess Face"
},
"trainFaceAs:": "Train Face as:",
"trainFaceAsPerson:": "Train Face as Person",
"toast": {
"success": {
"uploadedImage": "Successfully uploaded image.",
"addFaceLibrary": "Successfully add face library.",
"deletedFace": "Successfully deleted face.",
"trainedFace": "Successfully trained face.",
"updatedFaceScore": "Successfully updated face score."
},
"error": {
"uploadingImageFailed": "Failed to upload image: {{errorMessage}}",
"addFaceLibraryFailed": "Failed to set face name: {{errorMessage}}",
"deleteFaceFailed": "Failed to delete: {{errorMessage}}",
"trainFailed": "Failed to train: {{errorMessage}}",
"updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}"
}
"error": {
"uploadingImageFailed": "Failed to upload image: {{errorMessage}}",
"addFaceLibraryFailed": "Failed to set face name: {{errorMessage}}",
"deleteFaceFailed": "Failed to delete: {{errorMessage}}",
"trainFailed": "Failed to train: {{errorMessage}}",
"updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}"
}
}
}
}

View File

@ -100,19 +100,21 @@
"streamingSettings": "Streaming Settings",
"notifications": "Notifications",
"audio": "Audio",
"suspend:": {
"suspend": {
"forTime": "Suspend for: "
},
"stream": {
"title": "Stream",
"audio": {
"tips": "Audio must be output from your camera and configured in go2rtc for this stream.",
"tips.documentation": "Read the documentation ",
"tips": {
"title": "Audio must be output from your camera and configured in go2rtc for this stream.",
"documentation": "Read the documentation "
},
"available": "Audio is available for this stream",
"unavailable": "Audio is not available for this stream"
},
"twoWayTalk": {
"tips": "Your device must suppport the feature and WebRTC must be configured for two-way talk.",
"tips": "Your device must support the feature and WebRTC must be configured for two-way talk.",
"tips.documentation": "Read the documentation ",
"available": "Two-way talk is available for this stream",
"unavailable": "Two-way talk is unavailable for this stream"
@ -148,7 +150,9 @@
},
"editLayout": {
"label": "Edit Layout",
"group.label": "Edit Camera Group",
"group": {
"label": "Edit Camera Group"
},
"exitEdit": "Exit Editing"
}
}

View File

@ -1,65 +1,67 @@
{
"search": "Search",
"savedSearches": "Saved Searches",
"searchFor": "Search for {{inputValue}}",
"button": {
"clear": "Clear search",
"save": "Save search",
"delete": "Delete saved search",
"filterInformation": "Filter information",
"filterActive": "Filters active"
"search": "Search",
"savedSearches": "Saved Searches",
"searchFor": "Search for {{inputValue}}",
"button": {
"clear": "Clear search",
"save": "Save search",
"delete": "Delete saved search",
"filterInformation": "Filter information",
"filterActive": "Filters active"
},
"trackedObjectId": "Tracked Object ID",
"filter": {
"label": {
"cameras": "Cameras",
"labels": "Labels",
"zones": "Zones",
"sub_labels": "Sub Labels",
"search_type": "Search Type",
"time_range": "Time Range",
"before": "Before",
"after": "After",
"min_score": "Min Score",
"max_score": "Max Score",
"min_speed": "Min Speed",
"max_speed": "Max Speed",
"recognized_license_plate": "Recognized License Plate",
"has_clip": "Has Clip",
"has_snapshot": "Has Snapshot"
},
"trackedObjectId": "Tracked Object ID",
"filter": {
"label": {
"cameras": "Cameras",
"labels": "Labels",
"zones": "Zones",
"sub_labels": "Sub Labels",
"search_type": "Search Type",
"time_range": "Time Range",
"before": "Before",
"after": "After",
"min_score": "Min Score",
"max_score": "Max Score",
"min_speed": "Min Speed",
"max_speed": "Max Speed",
"recognized_license_plate": "Recognized License Plate",
"has_clip": "Has Clip",
"has_snapshot": "Has Snapshot"
},
"searchType": {
"thumbnail": "Thumbnail",
"description": "Description"
},
"toast": {
"error": {
"beforeDateBeLaterAfter": "The 'before' date must be later than the 'after' date.",
"afterDatebeEarlierBefore": "The 'after' date must be earlier than the 'before' date.",
"minScoreMustBeLessOrEqualMaxScore": "The 'min_score' must be less than or equal to the 'max_score'.",
"maxScoreMustBeGreaterOrEqualMinScore": "The 'max_score' must be greater than or equal to the 'min_score'.",
"minSpeedMustBeLessOrEqualMaxSpeed": "The 'min_speed' must be less than or equal to the 'max_speed'.",
"maxSpeedMustBeGreaterOrEqualMinSpeed": "The 'max_speed' must be greater than or equal to the 'min_speed'."
}
},
"tips": {
"title": "How to use text filters",
"desc": "Filters help you narrow down your search results. Here's how to use them in the input field:",
"desc.step": "<ul className=\"list-disc pl-5 text-sm text-primary-variant\"><li>Type a filter name followed by a colon (e.g., \"cameras:\").</li><li>Select a value from the suggestions or type your own.</li><li>Use multiple filters by adding them one after another with a space in between.</li><li>Date filters (before: and after:) use <em>{{DateFormat}}</em> format.</li><li>Time range filter uses <em>{{exampleTime}}</em> format.</li><li>Remove filters by clicking the 'x' next to them.</li></ul>",
"desc.example": "Example: <code className=\"text-primary\">cameras:front_door label:person before:01012024 time_range:3:00PM-4:00PM </code>"
},
"header": {
"currentFilterType": "Filter Values",
"noFilters": "Filters",
"activeFilters": "Active Filters"
}
"searchType": {
"thumbnail": "Thumbnail",
"description": "Description"
},
"similaritySearch": {
"title": "Similarity Search",
"active": "Similarity search active",
"clear": "Clear similarity search"
"toast": {
"error": {
"beforeDateBeLaterAfter": "The 'before' date must be later than the 'after' date.",
"afterDatebeEarlierBefore": "The 'after' date must be earlier than the 'before' date.",
"minScoreMustBeLessOrEqualMaxScore": "The 'min_score' must be less than or equal to the 'max_score'.",
"maxScoreMustBeGreaterOrEqualMinScore": "The 'max_score' must be greater than or equal to the 'min_score'.",
"minSpeedMustBeLessOrEqualMaxSpeed": "The 'min_speed' must be less than or equal to the 'max_speed'.",
"maxSpeedMustBeGreaterOrEqualMinSpeed": "The 'max_speed' must be greater than or equal to the 'min_speed'."
}
},
"placeholder": {
"search": "Search..."
"tips": {
"title": "How to use text filters",
"desc": {
"text": "Filters help you narrow down your search results. Here's how to use them in the input field:",
"step": "<ul className=\"list-disc pl-5 text-sm text-primary-variant\"><li>Type a filter name followed by a colon (e.g., \"cameras:\").</li><li>Select a value from the suggestions or type your own.</li><li>Use multiple filters by adding them one after another with a space in between.</li><li>Date filters (before: and after:) use <em>{{DateFormat}}</em> format.</li><li>Time range filter uses <em>{{exampleTime}}</em> format.</li><li>Remove filters by clicking the 'x' next to them.</li></ul>",
"example": "Example: <code className=\"text-primary\">cameras:front_door label:person before:01012024 time_range:3:00PM-4:00PM </code>"
}
},
"header": {
"currentFilterType": "Filter Values",
"noFilters": "Filters",
"activeFilters": "Active Filters"
}
}
},
"similaritySearch": {
"title": "Similarity Search",
"active": "Similarity search active",
"clear": "Clear similarity search"
},
"placeholder": {
"search": "Search..."
}
}

View File

@ -7,7 +7,8 @@
"masksAndZones": "Mask and Zone Editor - Frigate",
"motionTuner": "Motion Tuner - Frigate",
"object": "Object Settings - Frigate",
"general": "General Settings - Frigate"
"general": "General Settings - Frigate",
"frigatePlus": "Frigate+ Settings - Frigate"
},
"menu": {
"uiSettings": "UI Settings",
@ -17,7 +18,8 @@
"motionTuner": "Motion Tuner",
"debug": "Debug",
"users": "Users",
"notifications": "Notifications"
"notifications": "Notifications",
"frigateplus": "Frigate+"
},
"dialog": {
"unsavedChanges": {
@ -92,10 +94,14 @@
"modelSize": {
"label": "Model Size",
"desc": "The size of the model used for semantic search embeddings.",
"small": "small",
"large": "large",
"small.desc": "Using <em>small</em> employs a quantized version of the model that uses less RAM and runs faster on CPU with a very negligible difference in embedding quality.",
"large.desc": "Using <em>large</em> employs the full Jina model and will automatically run on the GPU if applicable."
"small": {
"title": "small",
"desc": "Using <em>small</em> employs a quantized version of the model that uses less RAM and runs faster on CPU with a very negligible difference in embedding quality."
},
"large": {
"title": "large",
"desc": "Using <em>large</em> employs the full Jina model and will automatically run on the GPU if applicable."
}
}
},
"faceRecognition": {
@ -133,9 +139,11 @@
"objectAlertsTips": "All {{alertsLabels}} objects on {{cameraName}} will be shown as Alerts.",
"zoneObjectAlertsTips": "All {{alertsLabels}} objects detected in {{zone}} on {{cameraName}} will be shown as Alerts.",
"objectDetectionsTips": "All {{detectionsLabels}} objects not categorized on {{cameraName}} will be shown as Detections regardless of which zone they are in.",
"zoneObjectDetectionsTips": "All {{detectionsLabels}} objects not categorized in {{zone}} on {{cameraName}} will be shown as Detections.",
"zoneObjectDetectionsTips.notSelectDetections": "All {{detectionsLabels}} objects detected in {{zone}} on {{cameraName}} not categorized as Alerts will be shown as Detections regardless of which zone they are in.",
"zoneObjectDetectionsTips.regardlessOfZoneObjectDetectionsTips": "All {{detectionsLabels}} objects not categorized on {{cameraName}} will be shown as Detections regardless of which zone they are in.",
"zoneObjectDetectionsTips": {
"text": "All {{detectionsLabels}} objects not categorized in {{zone}} on {{cameraName}} will be shown as Detections.",
"notSelectDetections": "All {{detectionsLabels}} objects detected in {{zone}} on {{cameraName}} not categorized as Alerts will be shown as Detections regardless of which zone they are in.",
"regardlessOfZoneObjectDetectionsTips": "All {{detectionsLabels}} objects not categorized on {{cameraName}} will be shown as Detections regardless of which zone they are in."
},
"selectAlertsZones": "Select zones for Alerts",
"selectDetectionsZones": "Select zones for Detections",
"limitDetections": "Limit detections to specific zones",
@ -166,13 +174,27 @@
"hasIllegalCharacter": "Zone name contains illegal characters."
}
},
"distance.error": "Distance must be greater than or equal to 0.1.",
"distance.error.mustBeFilled": "All distance fields must be filled to use speed estimation.",
"inertia.error.mustBeAboveZero": "Inertia must be above 0.",
"loiteringTime.error.mustBeGreaterOrEqualZero": "Loitering time must be greater than or equal to 0.",
"distance": {
"error": {
"text": "Distance must be greater than or equal to 0.1.",
"mustBeFilled": "All distance fields must be filled to use speed estimation."
}
},
"inertia": {
"error": {
"mustBeAboveZero": "Inertia must be above 0."
}
},
"loiteringTime": {
"error": {
"mustBeGreaterOrEqualZero": "Loitering time must be greater than or equal to 0."
}
},
"polygonDrawing": {
"removeLastPoint": "Remove last point",
"reset.label": "Clear all points",
"reset": {
"label": "Clear all points"
},
"snapPoints": {
"true": "Snap points",
"false": "Don't Snap points"
@ -190,77 +212,123 @@
"zones": {
"label": "Zones",
"documentTitle": "Edit Zone - Frigate",
"desc": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.",
"desc.documentation": "Documentation",
"desc": {
"title": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.",
"documentation": "Documentation"
},
"add": "Add Zone",
"edit": "Edit Zone",
"point_one": "{{count}} point",
"point_other": "{{count}} points",
"clickDrawPolygon": "Click to draw a polygon on the image.",
"name": "Name",
"name.inputPlaceHolder": "Enter a name...",
"name.tips": "Name must be at least 2 characters and must not be the name of a camera or another zone.",
"inertia": "Inertia",
"inertia.desc": "Specifies how many frames that an object must be in a zone before they are considered in the zone. <em>Default: 3</em>",
"loiteringTime": "Loitering Time",
"loiteringTime.desc": "Sets a minimum amount of time in seconds that the object must be in the zone for it to activate. <em>Default: 0</em>",
"objects": "Objects",
"objects.desc": "List of objects that apply to this zone.",
"name": {
"title": "Name",
"inputPlaceHolder": "Enter a name...",
"tips": "Name must be at least 2 characters and must not be the name of a camera or another zone."
},
"inertia": {
"title": "Inertia",
"desc": "Specifies how many frames that an object must be in a zone before they are considered in the zone. <em>Default: 3</em>"
},
"loiteringTime": {
"title": "Loitering Time",
"desc": "Sets a minimum amount of time in seconds that the object must be in the zone for it to activate. <em>Default: 0</em>"
},
"objects": {
"title": "Objects",
"desc": "List of objects that apply to this zone."
},
"allObjects": "All Objects",
"speedEstimation": "Speed Estimation",
"speedEstimation.desc": "Enable speed estimation for objects in this zone. The zone must have exactly 4 points.",
"speedThreshold": "Speed Threshold ({{unit}})",
"speedThreshold.desc": "Specifies a minimum speed for objects to be considered in this zone.",
"speedThreshold.toast.error.pointLengthError": "Speed estimation has been disabled for this zone. Zones with speed estimation must have exactly 4 points.",
"speedThreshold.toast.error.loiteringTimeError": "Zones with loitering times greater than 0 should not be used with speed estimation.",
"toast.success": "Zone ({{zoneName}}) has been saved. Restart Frigate to apply changes."
"speedEstimation": {
"title": "Speed Estimation",
"desc": "Enable speed estimation for objects in this zone. The zone must have exactly 4 points."
},
"speedThreshold": {
"title": "Speed Threshold ({{unit}})",
"desc": "Specifies a minimum speed for objects to be considered in this zone.",
"toast": {
"error": {
"pointLengthError": "Speed estimation has been disabled for this zone. Zones with speed estimation must have exactly 4 points.",
"loiteringTimeError": "Zones with loitering times greater than 0 should not be used with speed estimation."
}
}
},
"toast": {
"success": "Zone ({{zoneName}}) has been saved. Restart Frigate to apply changes."
}
},
"motionMasks": {
"label": "Motion Mask",
"documentTitle": "Edit Motion Mask - Frigate",
"desc": "Motion masks are used to prevent unwanted types of motion from triggering detection. Over masking will make it more difficult for objects to be tracked.",
"desc.documentation": "Documentation",
"desc": {
"title": "Motion masks are used to prevent unwanted types of motion from triggering detection. Over masking will make it more difficult for objects to be tracked.",
"documentation": "Documentation"
},
"add": "New Motion Mask",
"edit": "Edit Motion Mask",
"context": "Motion masks are used to prevent unwanted types of motion from triggering detection (example: tree branches, camera timestamps). Motion masks should be used <em>very sparingly</em>, over-masking will make it more difficult for objects to be tracked.",
"context.documentation": "Read the documentation",
"context": {
"title": "Motion masks are used to prevent unwanted types of motion from triggering detection (example: tree branches, camera timestamps). Motion masks should be used <em>very sparingly</em>, over-masking will make it more difficult for objects to be tracked.",
"documentation": "Read the documentation"
},
"point_one": "{{count}} point",
"point_other": "{{count}} points",
"clickDrawPolygon": "Click to draw a polygon on the image.",
"polygonAreaTooLarge": "The motion mask is covering {{polygonArea}}% of the camera frame. Large motion masks are not recommended.",
"polygonAreaTooLarge.tips": "Motion masks do not prevent objects from being detected. You should use a required zone instead.",
"polygonAreaTooLarge.documentation": "Read the documentation",
"toast.success": "{{polygonName}} has been saved. Restart Frigate to apply changes.",
"toast.success.noName": "Motion Mask has been saved. Restart Frigate to apply changes."
"polygonAreaTooLarge": {
"title": "The motion mask is covering {{polygonArea}}% of the camera frame. Large motion masks are not recommended.",
"tips": "Motion masks do not prevent objects from being detected. You should use a required zone instead.",
"documentation": "Read the documentation"
},
"toast": {
"success": {
"title": "{{polygonName}} has been saved. Restart Frigate to apply changes.",
"noName": "Motion Mask has been saved. Restart Frigate to apply changes."
}
}
},
"objectMasks": {
"label": "Object Masks",
"documentTitle": "Edit Object Mask - Frigate",
"desc": "Object filter masks are used to filter out false positives for a given object type based on location.",
"documentation": "Documentation",
"desc": {
"title": "Object filter masks are used to filter out false positives for a given object type based on location.",
"documentation": "Documentation"
},
"add": "Add Object Mask",
"edit": "Edit Object Mask",
"context": "Object filter masks are used to filter out false positives for a given object type based on location.",
"point_one": "{{count}} point",
"point_other": "{{count}} points",
"clickDrawPolygon": "Click to draw a polygon on the image.",
"objects": "Objects",
"objects.desc": "The object type that that applies to this object mask.",
"objects.allObjectTypes": "All object types",
"toast.success": "{{polygonName}} has been saved. Restart Frigate to apply changes.",
"toast.success.noName": "Object Mask has been saved. Restart Frigate to apply changes."
"objects": {
"title": "Objects",
"desc": "The object type that that applies to this object mask.",
"allObjectTypes": "All object types"
},
"toast": {
"success": {
"title": "{{polygonName}} has been saved. Restart Frigate to apply changes.",
"noName": "Object Mask has been saved. Restart Frigate to apply changes."
}
}
}
},
"motionDetectionTuner": {
"title": "Motion Detection Tuner",
"desc": "Frigate uses motion detection as a first line check to see if there is anything happening in the frame worth checking with object detection.",
"desc.documentation": "Read the Motion Tuning Guide",
"Threshold": "Threshold",
"Threshold.desc": "The threshold value dictates how much of a change in a pixel's luminance is required to be considered motion. <em>Default: 30</em>",
"contourArea": "Contour Area",
"contourArea.desc": "The contour area value is used to decide which groups of changed pixels qualify as motion. <em>Default: 10</em>",
"improveContrast": "Improve Contrast",
"improveContrast.desc": "Improve contrast for darker scenes. <em>Default: ON</em>",
"desc": {
"title": "Frigate uses motion detection as a first line check to see if there is anything happening in the frame worth checking with object detection.",
"documentation": "Read the Motion Tuning Guide"
},
"Threshold": {
"title": "Threshold",
"desc": "The threshold value dictates how much of a change in a pixel's luminance is required to be considered motion. <em>Default: 30</em>"
},
"contourArea": {
"title": "Contour Area",
"desc": "The contour area value is used to decide which groups of changed pixels qualify as motion. <em>Default: 10</em>"
},
"improveContrast": {
"title": "Improve Contrast",
"desc": "Improve contrast for darker scenes. <em>Default: ON</em>"
},
"toast": {
"success": "Motion settings have been saved."
}
@ -314,8 +382,10 @@
},
"users": {
"title": "Users",
"management": "Users Management",
"management.desc": "Manage this Frigate instance's user accounts.",
"management": {
"title": "User Management",
"desc": "Manage this Frigate instance's user accounts."
},
"addUser": "Add User",
"updatePassword": "Update Password",
"toast": {
@ -343,23 +413,35 @@
},
"dialog": {
"form": {
"user": "Username",
"user.desc": "Only letters, numbers, periods and underscores allowed.",
"user.placeholder": "Enter username",
"password": "Password",
"password.placeholder": "Enter password",
"password.confirm": "Confirm Password",
"password.confirm.placeholder": "Confirm Password",
"password.strength": "password strength: ",
"password.strength.weak": "Weak",
"password.strength.medium": "Medium",
"password.strength.strong": "Strong",
"password.strength.veryStrong": "Very strong",
"password.match": "Passwords match",
"password.notMatch": "Passwords don't match",
"newPassword": "New Password",
"newPassword.placeholder": "Enter new password",
"newPassword.confirm.placeholder": "Re-enter new password",
"user": {
"title": "Username",
"desc": "Only letters, numbers, periods and underscores allowed.",
"placeholder": "Enter username"
},
"password": {
"title": "Password",
"placeholder": "Enter password",
"confirm": {
"title": "Confirm Password",
"placeholder": "Confirm Password"
},
"strength": {
"title": "Password strength: ",
"weak": "Weak",
"medium": "Medium",
"strong": "Strong",
"veryStrong": "Very Strong"
},
"match": "Passwords match",
"notMatch": "Passwords don't match"
},
"newPassword": {
"title": "New Password",
"placeholder": "Enter new password",
"confirm": {
"placeholder": "Re-enter new password"
}
},
"usernameIsRequired": "Username is required"
},
"createUser": {
@ -400,12 +482,16 @@
"title": "Global Settings",
"desc": "Temporarily suspend notifications for specific cameras on all registered devices."
},
"email": "Email",
"email.placeholder": "e.g. example@email.com",
"email.desc": "A valid email is required and will be used to notify you if there are any issues with the push service.",
"cameras": "Cameras",
"cameras.noCameras": "No cameras available",
"cameras.desc": "Select which cameras to enable notifications for.",
"email": {
"title": "Email",
"placeholder": "e.g. example@email.com",
"desc": "A valid email is required and will be used to notify you if there are any issues with the push service."
},
"cameras": {
"title": "Cameras",
"noCameras": "No cameras available",
"desc": "Select which cameras to enable notifications for."
},
"deviceSpecific": "Device Specific Settings",
"registerDevice": "Register This Device",
"unregisterDevice": "Unregister This Device",
@ -431,5 +517,36 @@
"registerFailed": "Failed to save notification registration."
}
}
},
"frigatePlus": {
"title": "Frigate+ Settings",
"apiKey": {
"title": "Frigate+ API Key",
"validated": "Frigate+ API key is detected and validated",
"notValidated": "Frigate+ API key is not detected or not validated",
"desc": "The Frigate+ API key enables integration with the Frigate+ service.",
"plusLink": "Read more about Frigate+"
},
"snapshotConfig": {
"title": "Snapshot Configuration",
"desc": "Submitting to Frigate+ requires both snapshots and <code>clean_copy</code> snapshots to be enabled in your config.",
"documentation": "Read the documentation",
"cleanCopyWarning": "Some cameras have snapshots enabled but have the clean copy disabled. You need to enable <code>clean_copy</code> in your snapshot config to be able to submit images from these cameras to Frigate+.",
"table": {
"camera": "Camera",
"snapshots": "Snapshots",
"cleanCopySnapshots": "<code>clean_copy</code> Snapshots"
}
},
"modelInfo": {
"title": "Model Information",
"modelType": "Model Type",
"trainDate": "Train Date",
"baseModel": "Base Model",
"supportedDetectors": "Supported Detectors",
"cameras": "Cameras",
"loading": "Loading model information...",
"error": "Failed to load model information"
}
}
}

View File

@ -3,7 +3,7 @@
"cameras": "Cameras Stats - Frigate",
"storage": "Storage Stats - Frigate",
"general": "General Stats - Frigate",
"features": "Features Stats- Frigate",
"features": "Features Stats - Frigate",
"logs": {
"frigate": "Frigate Logs - Frigate",
"go2rtc": "Go2RTC Logs - Frigate",
@ -63,8 +63,12 @@
"cudaComputerCapability": "CUDA Compute Capability: {{cuda_compute}}",
"vbios": "VBios Info: {{vbios}}"
},
"closeInfo.label": "Close GPU info",
"copyInfo.label": "Copy GPU info",
"closeInfo": {
"label": "Close GPU info"
},
"copyInfo": {
"label": "Copy GPU info"
},
"toast": {
"success": "Copied GPU info to clipboard"
}
@ -87,12 +91,14 @@
"cameraStorage": {
"title": "Camera Storage",
"camera": "Camera",
"unused": "Unused",
"unusedStorageInformation": "Unused Storage Information",
"storageUsed": "Storage",
"percentageOfTotalUsed": "Percentage of Total",
"bandwidth": "Bandwidth",
"unused.tips": "This value may not accurately represent the free space available to Frigate if you have other files stored on your drive beyond Frigate's recordings. Frigate does not track storage usage outside of its recordings."
"unused": {
"title": "Unused",
"tips": "This value may not accurately represent the free space available to Frigate if you have other files stored on your drive beyond Frigate's recordings. Frigate does not track storage usage outside of its recordings."
}
}
},
"cameras": {
@ -139,6 +145,12 @@
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)"
},
"features": {
"title": "Features"
"title": "Features",
"embeddings": {
"image_embedding_speed": "Image Embedding Speed",
"face_embedding_speed": "Face Embedding Speed",
"plate_recognition_speed": "Plate Recognition Speed",
"text_embedding_speed": "Text Embedding Speed"
}
}
}

View File

@ -1,8 +1,429 @@
{
"crying": "哭泣",
"laughter": "笑声",
"scream": "尖叫",
"speech": "谈话",
"babbling": "喋喋不休",
"yell": "大喊",
"fire_alarm": "火灾警报器"
"bellow": "吼叫",
"whoop": "欢呼",
"whispering": "耳语",
"laughter": "笑声",
"snicker": "窃笑",
"crying": "哭泣",
"sigh": "叹息",
"singing": "唱歌",
"choir": "合唱",
"yodeling": "山歌",
"chant": "吟唱",
"mantra": "咒语",
"child_singing": "儿童歌唱",
"synthetic_singing": "合成歌声",
"rapping": "说唱",
"humming": "哼唱",
"groan": "呻吟",
"grunt": "咕哝",
"whistling": "口哨",
"breathing": "呼吸",
"wheeze": "喘息",
"snoring": "打鼾",
"gasp": "倒抽气",
"pant": "喘气",
"snort": "哼声",
"cough": "咳嗽",
"throat_clearing": "清嗓子",
"sneeze": "打喷嚏",
"sniff": "抽鼻子",
"run": "跑步",
"shuffle": "拖步",
"footsteps": "脚步声",
"chewing": "咀嚼",
"biting": "咬",
"gargling": "漱口",
"stomach_rumble": "肚子咕噜",
"burping": "打嗝",
"hiccup": "打嗝",
"fart": "放屁",
"hands": "手",
"finger_snapping": "打响指",
"clapping": "鼓掌",
"heartbeat": "心跳",
"heart_murmur": "心脏杂音",
"cheering": "欢呼",
"applause": "掌声",
"chatter": "闲聊",
"crowd": "人群",
"children_playing": "儿童玩耍",
"animal": "动物",
"pets": "宠物",
"dog": "狗",
"bark": "吠叫",
"yip": "吠叫",
"howl": "嚎叫",
"bow_wow": "汪汪",
"growling": "咆哮",
"whimper_dog": "狗呜咽",
"cat": "猫",
"purr": "咕噜",
"meow": "喵喵",
"hiss": "嘶嘶声",
"caterwaul": "猫叫春",
"livestock": "牲畜",
"horse": "马",
"clip_clop": "蹄声",
"neigh": "嘶鸣",
"cattle": "牛",
"moo": "哞哞",
"cowbell": "牛铃",
"pig": "猪",
"oink": "哼哼",
"goat": "山羊",
"bleat": "咩咩",
"sheep": "绵羊",
"fowl": "家禽",
"chicken": "鸡",
"cluck": "咯咯",
"cock_a_doodle_doo": "喔喔",
"turkey": "火鸡",
"gobble": "咯咯",
"duck": "鸭子",
"quack": "嘎嘎",
"goose": "鹅",
"honk": "鸣笛/鹅叫声",
"wild_animals": "野生动物",
"roaring_cats": "吼叫的猫科动物",
"roar": "吼叫",
"bird": "鸟",
"chirp": "啾啾",
"squawk": "啼叫",
"pigeon": "鸽子",
"coo": "咕咕",
"crow": "乌鸦",
"caw": "呱呱",
"owl": "猫头鹰",
"hoot": "呜呜",
"flapping_wings": "翅膀拍打",
"dogs": "狗群",
"rats": "老鼠",
"mouse": "老鼠",
"patter": "啪嗒声",
"insect": "昆虫",
"cricket": "蟋蟀",
"mosquito": "蚊子",
"fly": "苍蝇",
"buzz": "嗡嗡",
"frog": "青蛙",
"croak": "呱呱",
"snake": "蛇",
"rattle": "响尾",
"whale_vocalization": "鲸鱼叫声",
"music": "音乐",
"musical_instrument": "乐器",
"plucked_string_instrument": "弹拨乐器",
"guitar": "吉他",
"electric_guitar": "电吉他",
"bass_guitar": "贝斯",
"acoustic_guitar": "原声吉他",
"steel_guitar": "钢弦吉他",
"tapping": "敲击",
"strum": "扫弦",
"banjo": "班卓琴",
"sitar": "西塔琴",
"mandolin": "曼陀林",
"zither": "古筝",
"ukulele": "尤克里里",
"keyboard": "键盘",
"piano": "钢琴",
"electric_piano": "电钢琴",
"organ": "风琴",
"electronic_organ": "电子琴",
"hammond_organ": "哈蒙德风琴",
"synthesizer": "合成器",
"sampler": "采样器",
"harpsichord": "大键琴",
"percussion": "打击乐器",
"drum_kit": "架子鼓",
"drum_machine": "鼓机",
"drum": "鼓",
"snare_drum": "军鼓",
"rimshot": "鼓边击",
"drum_roll": "滚鼓",
"bass_drum": "大鼓",
"timpani": "定音鼓",
"tabla": "塔布拉鼓",
"cymbal": "钹",
"hi_hat": "踩镲",
"wood_block": "木鱼",
"tambourine": "铃鼓",
"maraca": "沙锤",
"gong": "锣",
"tubular_bells": "管钟",
"mallet_percussion": "槌击打击乐器",
"marimba": "马林巴",
"glockenspiel": "钟琴",
"vibraphone": "颤音琴",
"steelpan": "钢鼓",
"orchestra": "管弦乐队",
"brass_instrument": "铜管乐器",
"french_horn": "圆号",
"trumpet": "小号",
"trombone": "长号",
"bowed_string_instrument": "弓弦乐器",
"string_section": "弦乐组",
"violin": "小提琴",
"pizzicato": "拨弦",
"cello": "大提琴",
"double_bass": "低音提琴",
"wind_instrument": "管乐器",
"flute": "长笛",
"saxophone": "萨克斯",
"clarinet": "单簧管",
"harp": "竖琴",
"bell": "铃",
"church_bell": "教堂钟",
"jingle_bell": "铃铛",
"bicycle_bell": "自行车铃",
"tuning_fork": "音叉",
"chime": "风铃",
"wind_chime": "风铃",
"harmonica": "口琴",
"accordion": "手风琴",
"bagpipes": "风笛",
"didgeridoo": "迪吉里杜管",
"theremin": "特雷门琴",
"singing_bowl": "颂钵",
"scratching": "刮擦声",
"pop_music": "流行音乐",
"hip_hop_music": "嘻哈音乐",
"beatboxing": "人声节拍",
"rock_music": "摇滚音乐",
"heavy_metal": "重金属",
"punk_rock": "朋克摇滚",
"grunge": "垃圾摇滚",
"progressive_rock": "前卫摇滚",
"rock_and_roll": "摇滚乐",
"psychedelic_rock": "迷幻摇滚",
"rhythm_and_blues": "节奏布鲁斯",
"soul_music": "灵魂乐",
"reggae": "雷鬼",
"country": "乡村音乐",
"swing_music": "摇摆乐",
"bluegrass": "蓝草音乐",
"funk": "放克",
"folk_music": "民谣",
"middle_eastern_music": "中东音乐",
"jazz": "爵士乐",
"disco": "迪斯科",
"classical_music": "古典音乐",
"opera": "歌剧",
"electronic_music": "电子音乐",
"house_music": "浩室音乐",
"techno": "科技舞曲",
"dubstep": "回响贝斯",
"drum_and_bass": "鼓打贝斯",
"electronica": "电子乐",
"electronic_dance_music": "电子舞曲",
"ambient_music": "环境音乐",
"trance_music": "迷幻舞曲",
"music_of_latin_america": "拉丁美洲音乐",
"salsa_music": "萨尔萨",
"flamenco": "弗拉门戈",
"blues": "蓝调",
"music_for_children": "儿童音乐",
"new-age_music": "新世纪音乐",
"vocal_music": "声乐",
"a_capella": "无伴奏合唱",
"music_of_africa": "非洲音乐",
"afrobeat": "非洲节拍",
"christian_music": "基督教音乐",
"gospel_music": "福音音乐",
"music_of_asia": "亚洲音乐",
"carnatic_music": "卡纳提克音乐",
"music_of_bollywood": "宝莱坞音乐",
"ska": "斯卡",
"traditional_music": "传统音乐",
"independent_music": "独立音乐",
"song": "歌曲",
"background_music": "背景音乐",
"theme_music": "主题音乐",
"jingle": "广告歌",
"soundtrack_music": "配乐",
"lullaby": "摇篮曲",
"video_game_music": "电子游戏音乐",
"christmas_music": "圣诞音乐",
"dance_music": "舞曲",
"wedding_music": "婚礼音乐",
"happy_music": "欢快音乐",
"sad_music": "悲伤音乐",
"tender_music": "温柔音乐",
"exciting_music": "激动音乐",
"angry_music": "愤怒音乐",
"scary_music": "恐怖音乐",
"wind": "风",
"rustling_leaves": "树叶沙沙声",
"wind_noise": "风声",
"thunderstorm": "雷暴",
"thunder": "雷声",
"water": "水",
"rain": "雨",
"raindrop": "雨滴",
"rain_on_surface": "雨打表面",
"stream": "溪流",
"waterfall": "瀑布",
"ocean": "海洋",
"waves": "波浪",
"steam": "蒸汽",
"gurgling": "汩汩声",
"fire": "火",
"crackle": "噼啪声",
"vehicle": "车辆",
"boat": "船",
"sailboat": "帆船",
"rowboat": "划艇",
"motorboat": "摩托艇",
"ship": "轮船",
"motor_vehicle": "机动车",
"car": "汽车",
"toot": "鸣笛",
"car_alarm": "汽车警报",
"power_windows": "电动车窗",
"skidding": "轮胎打滑",
"tire_squeal": "轮胎尖叫",
"car_passing_by": "汽车驶过",
"race_car": "赛车",
"truck": "卡车",
"air_brake": "气闸",
"air_horn": "气笛",
"reversing_beeps": "倒车提示音",
"ice_cream_truck": "冰淇淋车",
"bus": "公共汽车",
"emergency_vehicle": "应急车辆",
"police_car": "警车",
"ambulance": "救护车",
"fire_engine": "消防车",
"motorcycle": "摩托车",
"traffic_noise": "交通噪音",
"rail_transport": "铁路运输",
"train": "火车",
"train_whistle": "火车汽笛",
"train_horn": "火车鸣笛",
"railroad_car": "铁路车厢",
"train_wheels_squealing": "火车轮子尖叫",
"subway": "地铁",
"aircraft": "飞行器",
"aircraft_engine": "飞机引擎",
"jet_engine": "喷气引擎",
"propeller": "螺旋桨",
"helicopter": "直升机",
"fixed-wing_aircraft": "固定翼飞机",
"bicycle": "自行车",
"skateboard": "滑板",
"engine": "引擎",
"light_engine": "轻型引擎",
"dental_drill's_drill": "牙科钻",
"lawn_mower": "割草机",
"chainsaw": "电锯",
"medium_engine": "中型引擎",
"heavy_engine": "重型引擎",
"engine_knocking": "引擎敲击",
"engine_starting": "引擎启动",
"idling": "怠速",
"accelerating": "加速",
"door": "门",
"doorbell": "门铃",
"ding-dong": "叮咚",
"sliding_door": "滑动门",
"slam": "猛关",
"knock": "敲门",
"tap": "轻敲",
"squeak": "吱吱声",
"cupboard_open_or_close": "橱柜开关",
"drawer_open_or_close": "抽屉开关",
"dishes": "餐具",
"cutlery": "刀叉",
"chopping": "切菜",
"frying": "煎炸",
"microwave_oven": "微波炉",
"blender": "搅拌机",
"water_tap": "水龙头",
"sink": "水槽",
"bathtub": "浴缸",
"hair_dryer": "吹风机",
"toilet_flush": "马桶冲水",
"toothbrush": "牙刷",
"electric_toothbrush": "电动牙刷",
"vacuum_cleaner": "吸尘器",
"zipper": "拉链",
"keys_jangling": "钥匙叮当",
"coin": "硬币",
"scissors": "剪刀",
"electric_shaver": "电动剃须刀",
"shuffling_cards": "洗牌",
"typing": "打字",
"typewriter": "打字机",
"computer_keyboard": "电脑键盘",
"writing": "书写",
"alarm": "警报",
"telephone": "电话",
"telephone_bell_ringing": "电话铃声",
"ringtone": "手机铃声",
"telephone_dialing": "电话拨号",
"dial_tone": "拨号音",
"busy_signal": "忙音",
"alarm_clock": "闹钟",
"siren": "警笛",
"civil_defense_siren": "防空警报",
"buzzer": "蜂鸣器",
"smoke_detector": "烟雾探测器",
"fire_alarm": "火灾警报器",
"foghorn": "雾笛",
"whistle": "哨子",
"steam_whistle": "蒸汽汽笛",
"mechanisms": "机械装置",
"ratchet": "棘轮",
"clock": "时钟",
"tick": "滴答",
"tick-tock": "滴答滴答",
"gears": "齿轮",
"pulleys": "滑轮",
"sewing_machine": "缝纫机",
"mechanical_fan": "机械风扇",
"air_conditioning": "空调",
"cash_register": "收银机",
"printer": "打印机",
"camera": "相机",
"single-lens_reflex_camera": "单反相机",
"tools": "工具",
"hammer": "锤子",
"jackhammer": "风镐",
"sawing": "锯",
"filing": "锉",
"sanding": "砂磨",
"power_tool": "电动工具",
"drill": "电钻",
"explosion": "爆炸",
"gunshot": "枪声",
"machine_gun": "机关枪",
"fusillade": "齐射",
"artillery_fire": "炮火",
"cap_gun": "玩具枪",
"fireworks": "烟花",
"firecracker": "鞭炮",
"burst": "爆裂",
"eruption": "爆发",
"boom": "轰隆",
"wood": "木头",
"chop": "砍",
"splinter": "碎裂",
"crack": "破裂",
"glass": "玻璃",
"chink": "叮当",
"shatter": "粉碎",
"silence": "寂静",
"sound_effect": "音效",
"environmental_noise": "环境噪音",
"static": "静电噪音",
"white_noise": "白噪音",
"pink_noise": "粉红噪音",
"television": "电视",
"radio": "收音机",
"field_recording": "实地录音",
"scream": "尖叫"
}

View File

@ -20,8 +20,8 @@
"1hour": "1 小时",
"12hours": "12 小时",
"24hours": "24 小时",
"pm": "午",
"am": "午",
"pm": "午",
"am": "午",
"yr": "{{time}}年",
"year": "{{time}}年",
"mo": "{{time}}月",
@ -34,14 +34,22 @@
"minute": "{{time}}分钟",
"s": "{{time}}秒",
"second": "{{time}}秒",
"formattedTimestamp": "%m月%-d日 %I:%M:%S %p",
"formattedTimestamp.24hour": "%m月%-d日 %H:%M:%S",
"formattedTimestamp2": "%m/%d %I:%M:%S%P",
"formattedTimestamp2.24hour": "%d日%m月 %H:%M:%S",
"formattedTimestampExcludeSeconds": "%m月%-d日 %I:%M %p",
"formattedTimestampExcludeSeconds.24hour": "%m月%-d日 %H:%M",
"formattedTimestampWithYear": "%Y年%m月%-d日 %I:%M:%S %p",
"formattedTimestampWithYear.24hour": "%Y年%m月%-d日 %H:%M",
"formattedTimestamp": {
"12hour": "%m月%-d日 %I:%M:%S %p",
"24hour": "%m月%-d日 %H:%M:%S"
},
"formattedTimestamp2": {
"12hour": "%m/%d %I:%M:%S%P",
"24hour": "%d日%m月 %H:%M:%S"
},
"formattedTimestampExcludeSeconds": {
"12hour": "%m月%-d日 %I:%M %p",
"24hour": "%m月%-d日 %H:%M"
},
"formattedTimestampWithYear": {
"12hour": "%Y年%m月%-d日 %I:%M:%S %p",
"24hour": "%Y年%m月%-d日 %H:%M"
},
"formattedTimestampOnlyMonthAndDay": "%m月%-d日"
},
"unit": {
@ -55,10 +63,14 @@
},
"pagination": {
"label": "分页",
"previous": "上一页",
"previous.label": "转到上一页",
"next": "下一页",
"next.label": "转到下一页",
"previous": {
"title": "上一页",
"label": "转到上一页"
},
"next": {
"title": "下一页",
"label": "转到下一页"
},
"more": "更多页面"
},
"button": {
@ -76,7 +88,7 @@
"back": "返回",
"history": "历史",
"fullscreen": "全屏",
"exitFullscreen": "全屏",
"exitFullscreen": "退出全屏",
"pictureInPicture": "画中画",
"on": "开",
"off": "关",
@ -105,14 +117,18 @@
"language": {
"en": "English",
"zhCN": "简体中文",
"withSystem.label": "使用系统语言设置"
"withSystem": {
"label": "使用系统语言设置"
}
},
"appearance": "外观",
"darkMode": {
"label": "深色模式",
"light": "浅色",
"dark": "深色",
"withSystem.label": "使用系统深色模式设置"
"withSystem": {
"label": "使用系统深色模式设置"
}
},
"withSystem": "跟随系统",
"theme": {
@ -125,32 +141,42 @@
"default": "默认"
},
"help": "帮助",
"documentation.label": "Frigate 的官方文档",
"documentation": "文档",
"live": "实时监控",
"live.allCameras": "所有摄像头",
"live.cameras": "摄像头",
"live.cameras.count_one": "{{count}} 个摄像头",
"live.cameras.count_other": "{{count}} 个摄像头",
"documentation": {
"title": "文档",
"label": "Frigate 的官方文档"
},
"live": {
"title": "实时监控",
"allCameras": "所有摄像头",
"cameras": {
"title": "摄像头",
"count_one": "{{count}} 个摄像头",
"count_other": "{{count}} 个摄像头"
}
},
"review": "回放",
"explore": "探测",
"export": "导出",
"uiPlayground": "UI Playground",
"uiPlayground": "UI 演示",
"faceLibrary": "人脸管理",
"user": {
"account": "账号",
"current": "当前用户:{{user}}",
"anonymous": "匿名",
"logout": "登出",
"setPassword": "设置密码"
"setPassword": "设置密码",
"title": "用户"
},
"restart": "重启 Frigate"
},
"toast": {
"copyUrlToClipboard": "已复制链接到剪贴板。",
"save": {
"error": "保存配置信息失败: {{errorMessage}}",
"error.noMessage": "保存配置信息失败"
"title": "保存",
"error": {
"title": "保存配置信息失败: {{errorMessage}}",
"noMessage": "保存配置信息失败"
}
}
},
"role": {

View File

@ -5,13 +5,15 @@
"edit": "编辑摄像头组",
"delete": {
"label": "删除摄像头组",
"confirm": "确认删除",
"confirm.desc": "你确定要删除摄像头组 <em>{{name}}</em> 吗?"
"confirm": {
"title": "确认删除",
"desc": "你确定要删除摄像头组 <em>{{name}}</em> 吗?"
}
},
"name": {
"label": "名称",
"placeholder": "请输入名称",
"error": {
"errorMessage": {
"mustLeastCharacters": "摄像头组的名称必须至少有 2 个字符。",
"exists": "摄像头组名称已存在。",
"nameMustNotPeriod": "摄像头组名称不能包含英文句号(.)。",
@ -32,8 +34,10 @@
"audioIsAvailable": "此视频流支持音频",
"audioIsUnavailable": "此视频流不支持音频",
"audio": {
"tips": "音频必须从您的摄像头输出并在 go2rtc 中配置此流。",
"tips.document": "阅读文档(英文) "
"tips": {
"title": "音频必须从您的摄像头输出并在 go2rtc 中配置此流。",
"document": "阅读文档(英文) "
}
},
"streamMethod": {
"label": "视频流方法",
@ -48,8 +52,10 @@
},
"continuousStreaming": {
"label": "持续视频流",
"desc": "当摄像头画面在仪表板上可见时,始终为实时视频流,即使未检测到活动。",
"desc.warning": "持续视频流可能会导致高带宽使用和性能问题,请谨慎使用。"
"desc": {
"title": "当摄像头画面在仪表板上可见时,始终为实时视频流,即使未检测到活动。",
"warning": "持续视频流可能会导致高带宽使用和性能问题,请谨慎使用。"
}
}
}
},

View File

@ -15,13 +15,19 @@
"desc": "您希望避开的地点中的物体不应被视为误报。若将其作为误报提交可能会导致AI模型容易混淆相关物体的识别。"
},
"review": {
"true.label": "为 Frigate Plus 确认此标签",
"true_one": "这是 {{label}}",
"true_other": "这是 {{label}}",
"false.label": "不为 Frigate Plus 确认此标签",
"false_one": "这不是 {{label}}",
"false_other": "这不是 {{label}}",
"state.submitted": "已提交"
"true": {
"label": "为 Frigate Plus 确认此标签",
"true_one": "这是 {{label}}",
"true_other": "这是 {{label}}"
},
"false": {
"label": "不为 Frigate Plus 确认此标签",
"false_one": "这不是 {{label}}",
"false_other": "这不是 {{label}}"
},
"state": {
"submitted": "已提交"
}
}
},
"video": {
@ -34,10 +40,14 @@
"lastHour_one": "最后1小时",
"lastHour_other": "最后 {{count}} 小时",
"custom": "自定义",
"start": "开始时间",
"start.label": "选择开始时间",
"end": "结束时间",
"end.label": "选择结束时间"
"start": {
"title": "开始时间",
"label": "选择开始时间"
},
"end": {
"title": "结束时间",
"label": "选择结束时间"
}
},
"name": {
"placeholder": "导出项目的名字"
@ -61,9 +71,11 @@
"streaming": {
"label": "视频流",
"restreaming": {
"NotEnabled": "重新流式传输未启用。",
"desc": "为此摄像头设置 go2rtc以获取额外的实时预览选项和音频支持。",
"desc.readTheDocumentation": "阅读文档(英文) "
"disabled": "此摄像头未启用视频流转发功能。",
"desc": {
"title": "为此摄像头设置 go2rtc以获取额外的实时预览选项和音频支持。",
"readTheDocumentation": "阅读文档(英文) "
}
},
"showStats": {
"label": "显示视频流统计信息",
@ -78,14 +90,19 @@
"placeholder": "请输入搜索名称",
"overwrite": "{{searchName}} 已存在。保存将覆盖现有值。",
"success": "搜索 ({{searchName}}) 已保存。",
"button.save.label": "保存此搜索"
"button": {
"save": {
"label": "保存此搜索"
}
}
}
},
"recording": {
"confirmDelete": {
"title": "确认删除",
"desc": "您确定要删除与此审核项相关的所有录制视频吗?<br /><br />提示:按住 <em>Shift</em> 键点击删除可跳过此对话框。",
"desc.selected": "您确定要删除与此审核项相关的所有录制视频吗?<br /><br />提示:按住 <em>Shift</em> 键点击删除可跳过此对话框。"
"desc": {
"selected": "您确定要删除与此审核项相关的所有录制视频吗?<br /><br />提示:按住 <em>Shift</em> 键点击删除可跳过此对话框。"
}
},
"button": {
"export": "导出",

View File

@ -2,22 +2,30 @@
"filter": "过滤器",
"labels": {
"label": "标签",
"all": "所有标签",
"all.short": "标签",
"all": {
"title": "所有标签",
"short": "标签"
},
"count": "{{count}} 个标签"
},
"zones": {
"all": "所有区域",
"all.short": "区域"
"all": {
"title": "所有区域",
"short": "区域"
},
"label": "区域"
},
"dates": {
"all": "所有日期",
"all.short": "日期"
"all": {
"title": "所有日期",
"short": "日期"
}
},
"more": "更多筛选项",
"reset.label": "重置筛选器为默认值",
"reset": {
"label": "重置筛选器为默认值"
},
"timeRange": "时间范围",
"zones.label": "区域",
"subLabels": {
"label": "子标签",
"all": "所有子标签"
@ -45,8 +53,10 @@
},
"cameras": {
"label": "摄像头筛选",
"all": "所有摄像头",
"all.short": "摄像头"
"all": {
"title": "所有摄像头",
"short": "摄像头"
}
},
"review": {
"showReviewed": "显示已查看的项目"
@ -57,12 +67,16 @@
"explore": {
"settings": {
"title": "设置",
"defaultView": "默认视图",
"defaultView.desc": "当未选择任何过滤器时,显示每个标签最近跟踪对象的摘要,或显示未过滤的网格。",
"defaultView.summary": "摘要",
"defaultView.unfilteredGrid": "未过滤网格",
"gridColumns": "网格列数",
"gridColumns.desc": "选择网格视图中的列数。",
"defaultView": {
"title": "默认视图",
"desc": "当未选择任何过滤器时,显示每个标签最近跟踪对象的摘要,或显示未过滤的网格。",
"summary": "摘要",
"unfilteredGrid": "未过滤网格"
},
"gridColumns": {
"title": "网格列数",
"desc": "选择网格视图中的列数。"
},
"searchSource": {
"label": "搜索源",
"desc": "选择是搜索缩略图还是跟踪对象的描述。",
@ -70,19 +84,21 @@
"thumbnailImage": "缩略图",
"description": "描述"
}
},
"date": {
"selectDateBy": {
"label": "选择日期进行筛选"
}
}
},
"date": {
"selectDateBy": {
"label": "选择日期进行筛选"
}
}
},
"logSettings": {
"label": "日志级别筛选",
"filterBySeverity": "按严重程度筛选日志",
"loading": "加载中",
"loading.desc": "当日志面板滚动到底部时,新的日志会自动流式加载。",
"loading": {
"title": "加载中",
"desc": "当日志面板滚动到底部时,新的日志会自动流式加载。"
},
"disableLogStreaming": "禁用日志流式加载",
"allLogs": "所有日志"
},

View File

@ -3,7 +3,7 @@
"downloadVideo": {
"label": "下载视频",
"toast": {
"success": "下载成功"
"success": "您的回放视频已开始下载。"
}
}
}

View File

@ -13,18 +13,30 @@
},
"cameraDisabled": "摄像机已禁用",
"stats": {
"streamType": "流类型:",
"streamType.short": "类型",
"bandwidth": "带宽:",
"bandwidth.short": "带宽",
"latency": "延迟:",
"latency.short": "延迟",
"latency.value": "{{seconds}} 秒",
"latency.short.value": "{{seconds}} 秒",
"streamType": {
"title": "流类型:",
"short": "类型"
},
"bandwidth": {
"title": "带宽:",
"short": "带宽"
},
"latency": {
"title": "延迟:",
"value": "{{seconds}} 秒",
"short": {
"title": "延迟",
"value": "{{seconds}} 秒"
}
},
"totalFrames": "总帧数:",
"droppedFrames": "丢帧数:",
"droppedFrames.short": "丢帧",
"droppedFrames.short.value": "{{droppedFrames}} 帧",
"droppedFrames": {
"title": "丢帧数:",
"short": {
"title": "丢帧",
"value": "{{droppedFrames}} 帧"
}
},
"decodedFrames": "解码帧数:",
"droppedFrameRate": "丢帧率:"
},

View File

@ -100,5 +100,21 @@
"raccoon": "浣熊",
"robot_lawnmower": "自动割草机",
"waste_bin": "垃圾桶",
"on_demand": "手动"
"on_demand": "手动",
"face": "人脸",
"license_plate": "车牌",
"package": "包裹",
"bbq_grill": "烧烤架",
"amazon": "亚马逊",
"usps": "美国邮政",
"ups": "UPS",
"fedex": "联邦快递",
"dhl": "DHL",
"an_post": "爱尔兰邮政",
"purolator": "普罗莱特",
"postnl": "荷兰邮政",
"nzpost": "新西兰邮政",
"postnord": "北欧邮政",
"gls": "GLS",
"dpd": "DPD"
}

View File

@ -29,7 +29,7 @@
"error": "发生错误。请检查Frigate日志。"
}
},
"trackedObjectDetails": "探测对象详情",
"trackedObjectDetails": "跟踪对象详情",
"type": {
"details": "详情",
"snapshot": "快照",
@ -58,8 +58,10 @@
},
"annotationSettings": {
"title": "标注设置",
"showAllZones": "显示所有区域",
"showAllZones.desc": "在对象进入区域的帧上始终显示区域。",
"showAllZones": {
"title": "显示所有区域",
"desc": "在对象进入区域的帧上始终显示区域。"
},
"offset": {
"label": "标注偏移",
"desc": "这些数据来自摄像头的检测源,但是叠加在录制源的图像上。这两个流不太可能完全同步。因此,边界框和录像不会完全对齐。但是,可以使用 <code>annotation_offset</code> 字段来调整这个问题。",
@ -98,26 +100,32 @@
}
},
"label": "标签",
"editSubLable": "编辑子标签",
"editSubLable.desc": "为 {{label}} 输入新的子标签",
"editSubLable.desc.noLabel": "为此跟踪对象输入新的子标签",
"topScore": "最高得分",
"topScore.info": "最高分是跟踪对象的最高中位数得分,因此可能与搜索结果缩略图上显示的得分不同。",
"editSubLabel": {
"title": "编辑子标签",
"desc": "为 {{label}} 输入新的子标签",
"descNoLabel": "为此跟踪对象输入新的子标签"
},
"topScore": {
"label": "最高得分",
"info": "最高分是跟踪对象的最高中位数得分,因此可能与搜索结果缩略图上显示的得分不同。"
},
"estimatedSpeed": "预计速度",
"objects": "对象",
"camera": "摄像头",
"zones": "区域",
"timestamp": "时间",
"button": {
"findSimilar": "查找相似项"
"findSimilar": "查找相似项",
"regenerate": {
"title": "重新生成",
"label": "重新生成跟踪对象描述"
}
},
"description": {
"label": "描述",
"placeholder": "跟踪对象的描述",
"aiTips": "在跟踪对象的生命周期结束之前Frigate 不会向您的生成式 AI 提供商请求描述。"
},
"button.regenerate": "重新生成",
"button.regenerate.label": "重新生成跟踪对象描述",
"expandRegenerationMenu": "展开重新生成菜单",
"regenerateFromSnapshot": "从快照重新生成",
"regenerateFromThumbnails": "从缩略图重新生成",
@ -161,7 +169,7 @@
"desc": "删除此跟踪对象将移除快照、所有已保存的嵌入数据以及任何关联的对象生命周期条目。但在历史视图中的录制视频<em>不会</em>被删除。<br /><br />你确定要继续删除吗?"
}
},
"noTrackedObjects": "找不到探测的对象",
"noTrackedObjects": "未找到跟踪对象",
"fetchingTrackedObjectsFailed": "获取跟踪对象失败:{{errorMessage}}",
"trackedObjectsCount": "{{count}} 个跟踪对象",
"searchResult": {

View File

@ -1,41 +1,41 @@
{
"documentTitle": "人脸库 - Frigate",
"uploadFaceImage": {
"title": "上传人脸图片",
"desc": "上传图片以扫描人脸并包含在{{pageToggle}}中"
"documentTitle": "人脸库 - Frigate",
"uploadFaceImage": {
"title": "上传人脸图片",
"desc": "上传图片以扫描人脸并包含在{{pageToggle}}中"
},
"createFaceLibrary": {
"title": "创建人脸库",
"desc": "创建一个新的人脸库"
},
"train": {
"title": "训练",
"aria": "选择训练"
},
"selectItem": "选择{{item}}",
"button": {
"deleteFaceAttempts": "尝试删除人脸",
"addFace": "添加人脸",
"uploadImage": "上传图片",
"reprocessFace": "重新处理人脸"
},
"trainFaceAs": "将人脸训练为:",
"trainFaceAsPerson": "将人脸训练为人物",
"toast": {
"success": {
"uploadedImage": "图片上传成功。",
"addFaceLibrary": "人脸库添加成功。",
"deletedFace": "人脸删除成功。",
"trainedFace": "人脸训练成功。",
"updatedFaceScore": "人脸分数更新成功。"
},
"createFaceLibrary": {
"title": "创建人脸库",
"desc": "创建一个新的人脸库"
},
"train": {
"title": "训练",
"aria": "选择训练"
},
"selectItem": "选择{{item}}",
"button": {
"deleteFaceAttempts": "尝试删除人脸",
"addFace": "添加人脸",
"uploadImage": "上传图片",
"reprocessFace:": "重新处理人脸"
},
"trainFaceAs:": "将人脸训练为:",
"trainFaceAsPerson:": "将人脸训练为人物",
"toast": {
"success": {
"uploadedImage": "图片上传成功。",
"addFaceLibrary": "人脸库添加成功。",
"deletedFace": "人脸删除成功。",
"trainedFace": "人脸训练成功。",
"updatedFaceScore": "人脸分数更新成功。"
},
"error": {
"uploadingImageFailed": "图片上传失败:{{errorMessage}}",
"addFaceLibraryFailed": "设置人脸名称失败:{{errorMessage}}",
"deleteFaceFailed": "删除失败:{{errorMessage}}",
"trainFailed": "训练失败:{{errorMessage}}",
"updateFaceScoreFailed": "更新人脸分数失败:{{errorMessage}}"
}
"error": {
"uploadingImageFailed": "图片上传失败:{{errorMessage}}",
"addFaceLibraryFailed": "设置人脸名称失败:{{errorMessage}}",
"deleteFaceFailed": "删除失败:{{errorMessage}}",
"trainFailed": "训练失败:{{errorMessage}}",
"updateFaceScoreFailed": "更新人脸分数失败:{{errorMessage}}"
}
}
}

View File

@ -79,14 +79,14 @@
},
"manualRecording": {
"title": "按需录制",
"tips": "根据此摄像的录制保留设置,手动启动事件。",
"tips": "根据此摄像的录制保留设置,手动启动事件。",
"playInBackground": {
"label": "后台播放",
"desc": "启用此选项可在播放器隐藏时继续视频流播放。"
},
"showStats": {
"label": "显示统计信息",
"desc": "启用此选项可在摄像画面上叠加显示视频流统计信息。"
"desc": "启用此选项可在摄像画面上叠加显示视频流统计信息。"
},
"debugView": "调试视图",
"start": "开始手动按需录制",
@ -106,8 +106,10 @@
"stream": {
"title": "视频流",
"audio": {
"tips": "音频必须从摄像机输出并在 go2rtc 中配置为此视频流使用。",
"tips.documentation": "阅读文档 ",
"tips": {
"title": "音频必须从摄像头输出并在 go2rtc 中配置为此视频流使用。",
"documentation": "阅读文档 "
},
"available": "此视频流支持音频",
"unavailable": "此视频流不支持音频"
},
@ -128,7 +130,7 @@
},
"cameraSettings": {
"title": "{{camera}} 设置",
"cameraEnabled": "摄像已启用",
"cameraEnabled": "摄像已启用",
"objectDetection": "对象检测",
"recording": "录制",
"snapshots": "快照",
@ -148,7 +150,9 @@
},
"editLayout": {
"label": "编辑布局",
"group.label": "编辑摄像机分组",
"group": {
"label": "编辑摄像机分组"
},
"exitEdit": "退出编辑"
}
}
}

View File

@ -1,65 +1,67 @@
{
"search": "搜索",
"savedSearches": "已保存的搜索",
"searchFor": "搜索 {{inputValue}}",
"button": {
"clear": "清除搜索",
"save": "保存搜索",
"delete": "删除已保存的搜索",
"filterInformation": "筛选信息",
"filterActive": "筛选器已激活"
"search": "搜索",
"savedSearches": "已保存的搜索",
"searchFor": "搜索 {{inputValue}}",
"button": {
"clear": "清除搜索",
"save": "保存搜索",
"delete": "删除已保存的搜索",
"filterInformation": "筛选信息",
"filterActive": "筛选器已激活"
},
"trackedObjectId": "跟踪对象 ID",
"filter": {
"label": {
"cameras": "摄像机",
"labels": "标签",
"zones": "区域",
"sub_labels": "子标签",
"search_type": "搜索类型",
"time_range": "时间范围",
"before": "之前",
"after": "之后",
"min_score": "最低分数",
"max_score": "最高分数",
"min_speed": "最低速度",
"max_speed": "最高速度",
"recognized_license_plate": "识别的车牌",
"has_clip": "包含片段",
"has_snapshot": "包含快照"
},
"trackedObjectId": "跟踪对象 ID",
"filter": {
"label": {
"cameras": "摄像机",
"labels": "标签",
"zones": "区域",
"sub_labels": "子标签",
"search_type": "搜索类型",
"time_range": "时间范围",
"before": "之前",
"after": "之后",
"min_score": "最低分数",
"max_score": "最高分数",
"min_speed": "最低速度",
"max_speed": "最高速度",
"recognized_license_plate": "识别的车牌",
"has_clip": "包含片段",
"has_snapshot": "包含快照"
},
"searchType": {
"thumbnail": "缩略图",
"description": "描述"
},
"toast": {
"error": {
"beforeDateBeLaterAfter": "“之前”日期必须晚于“之后”日期。",
"afterDatebeEarlierBefore": "“之后”日期必须早于“之前”日期。",
"minScoreMustBeLessOrEqualMaxScore": "最小分值 必须小于或等于 最大分值。",
"maxScoreMustBeGreaterOrEqualMinScore": "最大分值 必须大于或等于 最小分值",
"minSpeedMustBeLessOrEqualMaxSpeed": "最低速度 必须小于或等于 最高速度",
"maxSpeedMustBeGreaterOrEqualMinSpeed": "最高速度 必须大于或等于 最低速度"
}
},
"tips": {
"title": "如何使用文本筛选器(英文)",
"desc": "筛选器可帮助您缩小搜索范围。注意,目前还暂不支持中文搜索。以下是在输入字段中使用筛选器的方法:",
"desc.step": "<ul className=\"list-disc pl-5 text-sm text-primary-variant\"><li>输入筛选器名称后跟一个冒号例如“cameras:”)。</li><li>从建议中选择一个值或输入您自己的值。</li><li>使用多个筛选器时,可以在它们之间用空格分隔。</li><li>日期筛选器before: 和 after:)使用 <em>{{DateFormat}}</em> 格式。</li><li>时间范围筛选器使用 <em>{{exampleTime}}</em> 格式。</li><li>点击筛选器旁边的“x”即可移除筛选条件。</li></ul>",
"desc.example": "示例:<code className=\"text-primary\">cameras:front_door label:person before:01012024 time_range:3:00PM-4:00PM</code>"
},
"header": {
"currentFilterType": "筛选值",
"noFilters": "筛选条件",
"activeFilters": "激活的筛选项"
}
"searchType": {
"thumbnail": "缩略图",
"description": "描述"
},
"similaritySearch": {
"title": "相似搜索",
"active": "相似搜索已激活",
"clear": "清除相似搜索"
"toast": {
"error": {
"beforeDateBeLaterAfter": "结束日期必须晚于开始日期。",
"afterDatebeEarlierBefore": "开始日期必须早于结束日期。",
"minScoreMustBeLessOrEqualMaxScore": "最低分数必须小于或等于最高分数。",
"maxScoreMustBeGreaterOrEqualMinScore": "最高分数必须大于或等于最低分数。",
"minSpeedMustBeLessOrEqualMaxSpeed": "最低速度必须小于或等于最高速度。",
"maxSpeedMustBeGreaterOrEqualMinSpeed": "最高速度必须大于或等于最低速度。"
}
},
"placeholder": {
"search": "搜索..."
"tips": {
"title": "如何使用文本筛选器(英文)",
"desc": {
"text": "筛选器可帮助您缩小搜索范围。注意,目前还暂不支持中文搜索。以下是在输入字段中使用筛选器的方法:",
"step": "<ul className=\"list-disc pl-5 text-sm text-primary-variant\"><li>输入筛选器名称后跟一个冒号例如“cameras:”)。</li><li>从建议中选择一个值或输入您自己的值。</li><li>使用多个筛选器时,可以在它们之间用空格分隔。</li><li>日期筛选器before: 和 after:)使用 <em>{{DateFormat}}</em> 格式。</li><li>时间范围筛选器使用 <em>{{exampleTime}}</em> 格式。</li><li>点击筛选器旁边的“x”即可移除筛选条件。</li></ul>",
"example": "示例:<code className=\"text-primary\">cameras:front_door label:person before:01012024 time_range:3:00PM-4:00PM</code>"
}
},
"header": {
"currentFilterType": "筛选值",
"noFilters": "筛选条件",
"activeFilters": "激活的筛选项"
}
},
"similaritySearch": {
"title": "相似搜索",
"active": "相似搜索已激活",
"clear": "清除相似搜索"
},
"placeholder": {
"search": "搜索..."
}
}

View File

@ -88,10 +88,14 @@
"modelSize": {
"label": "模型大小",
"desc": "用于语义搜索的语言模型大小",
"small": "小",
"large": "大",
"small.desc": "使用 <strong>小</strong>模型。该模型将使用较少的内存在CPU上也能较快的运行。质量较好。",
"large.desc": "使用 <strong>大</strong>模型。该模型采用了完整的Jina模型并在适用的情况下使用GPU。"
"small": {
"title": "小",
"desc": "使用 <strong>小</strong>模型。该模型将使用较少的内存在CPU上也能较快的运行。质量较好。"
},
"large": {
"title": "大",
"desc": "使用 <strong>大</strong>模型。该模型采用了完整的Jina模型并在适用的情况下使用GPU。"
}
}
},
"faceRecognition": {
@ -129,9 +133,11 @@
"objectAlertsTips": "所有的 {{alertsLabels}} 对象在 {{cameraName}} 都将显示为警告。",
"zoneObjectAlertsTips": "所有的 {{alertsLabels}} 对象在 {{cameraName}} 的 {{zone}} 区域都将显示为警告。",
"objectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。",
"zoneObjectDetectionsTips": "所有未在 {{cameraName}} 上归类为 {{detectionsLabels}} 的对象在 {{zone}} 区域都将显示为检测。",
"zoneObjectDetectionsTips.notSelectDetections": "所有在 {{cameraName}} 的 {{zone}} 上检测到的未归类为警告的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。",
"zoneObjectDetectionsTips.regardlessOfZoneObjectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。",
"zoneObjectDetectionsTips": {
"text": "所有未在 {{cameraName}} 上归类为 {{detectionsLabels}} 的对象在 {{zone}} 区域都将显示为检测。",
"notSelectDetections": "所有在 {{cameraName}} 的 {{zone}} 上检测到的未归类为警告的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。",
"regardlessOfZoneObjectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。"
},
"selectAlertsZones": "选择要显示为警告的区域",
"selectDetectionsZones": "选择检测区域",
"limitDetections": "限制仅在特定区域内进行检测",
@ -162,13 +168,27 @@
"hasIllegalCharacter": "区域名称包含非法字符。"
}
},
"distance.error": "距离必须大于或等于 0.1。",
"distance.error.mustBeFilled": "所有距离字段必须填写才能使用速度估算。",
"inertia.error.mustBeAboveZero": "惯性必须大于 0。",
"loiteringTime.error.mustBeGreaterOrEqualZero": "徘徊时间必须大于或等于 0。",
"distance": {
"error": {
"text": "距离必须大于或等于 0.1。",
"mustBeFilled": "所有距离字段必须填写才能使用速度估算。"
}
},
"inertia": {
"error": {
"mustBeAboveZero": "惯性必须大于 0。"
}
},
"loiteringTime": {
"error": {
"mustBeGreaterOrEqualZero": "徘徊时间必须大于或等于 0。"
}
},
"polygonDrawing": {
"removeLastPoint": "删除最后一个点",
"reset.label": "清除所有点",
"reset": {
"label": "清除所有点"
},
"snapPoints": {
"true": "启用点对齐",
"false": "禁用点对齐"
@ -186,77 +206,123 @@
"zones": {
"label": "区域",
"documentTitle": "编辑区域 - Frigate",
"desc": "该功能允许你定义特定区域,以便你可以确定特定对象是否在该区域内。",
"desc.documentation": "文档(英文)",
"desc": {
"title": "该功能允许你定义特定区域,以便你可以确定特定对象是否在该区域内。",
"documentation": "文档(英文)"
},
"add": "添加区域",
"edit": "编辑区域",
"point_one": "{{count}} 点",
"point_other": "{{count}} 点",
"clickDrawPolygon": "在图像上点击添加点绘制多边形区域。",
"name": "区域名称",
"name.inputPlaceHolder": "请输入名称",
"name.tips": "名称至少包含两个字符,且不能和摄像头或其他区域同名。<br>当前仅支持英文与数字组合",
"inertia": "惯性",
"inertia.desc": "识别指定对象前该对象必须在这个区域内出现了多少帧。<em>默认值3</em>",
"loiteringTime": "停留时间",
"loiteringTime.desc": "设置对象必须在区域中活动的最小时间(单位为秒)。<em>默认值0</em>",
"objects": "对象",
"objects.desc": "将在此区域应用的对象列表。",
"name": {
"title": "区域名称",
"inputPlaceHolder": "请输入名称",
"tips": "名称至少包含两个字符,且不能和摄像头或其他区域同名。<br>当前仅支持英文与数字组合"
},
"inertia": {
"title": "惯性",
"desc": "识别指定对象前该对象必须在这个区域内出现了多少帧。<em>默认值3</em>"
},
"loiteringTime": {
"title": "停留时间",
"desc": "设置对象必须在区域中活动的最小时间(单位为秒)。<em>默认值0</em>"
},
"objects": {
"title": "对象",
"desc": "将在此区域应用的对象列表。"
},
"allObjects": "所有对象",
"speedEstimation": "速度估算",
"speedEstimation.desc": "启用此区域内物体的速度估算。该区域必须恰好包含 4 个点。",
"speedThreshold": "速度阈值 ({{unit}})",
"speedThreshold.desc": "指定物体在此区域内被视为有效的最低速度。",
"speedThreshold.toast.error.pointLengthError": "此区域的速度估算已禁用。启用速度估算的区域必须恰好包含 4 个点。",
"speedThreshold.toast.error.loiteringTimeError": "徘徊时间大于 0 的区域不应与速度估算一起使用。",
"toast.success": "区域 ({{zoneName}}) 已保存。请重启 Frigate 以应用更改。"
"speedEstimation": {
"title": "速度估算",
"desc": "启用此区域内物体的速度估算。该区域必须恰好包含 4 个点。"
},
"speedThreshold": {
"title": "速度阈值 ({{unit}})",
"desc": "指定物体在此区域内被视为有效的最低速度。",
"toast": {
"error": {
"pointLengthError": "此区域的速度估算已禁用。启用速度估算的区域必须恰好包含 4 个点。",
"loiteringTimeError": "徘徊时间大于 0 的区域不应与速度估算一起使用。"
}
}
},
"toast": {
"success": "区域 ({{zoneName}}) 已保存。请重启 Frigate 以应用更改。"
}
},
"motionMasks": {
"label": "运动遮罩",
"documentTitle": "编辑运动遮罩 - Frigate",
"desc": "运动遮罩用于防止触发不必要的运动类型。过度的设置遮罩将使对象更加难以被追踪",
"desc.documentation": "文档(英文)",
"desc": {
"title": "运动遮罩用于防止触发不必要的运动类型。过度的设置遮罩将使对象更加难以被追踪",
"documentation": "文档(英文)"
},
"add": "添加运动遮罩",
"edit": "编辑运动遮罩",
"context": "运动遮罩用于防止不需要的运动类型触发检测(例如:树枝、摄像头显示的时间等)。运动遮罩需要<strong>谨慎使用</strong>,过度的遮罩会导致追踪对象变得更加困难。",
"context.documentation": "阅读文档(英文)",
"context": {
"title": "运动遮罩用于防止不需要的运动类型触发检测(例如:树枝、摄像头显示的时间等)。运动遮罩需要<strong>谨慎使用</strong>,过度的遮罩会导致追踪对象变得更加困难。",
"documentation": "阅读文档(英文)"
},
"point_one": "{{count}} 点",
"point_other": "{{count}} 点",
"clickDrawPolygon": "在图像上点击添加点绘制多边形区域。",
"polygonAreaTooLarge": "运动遮罩的大小达到了摄像头画面的{{polygonArea}}%。不建议设置太大的运动遮罩。",
"polygonAreaTooLarge.tips": "运动遮罩不会阻止检测到对象,你应该使用区域来限制检测对象。",
"polygonAreaTooLarge.documentation": "阅读文档(英文)",
"toast.success": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。",
"toast.success.noName": "运动遮罩已保存。请重启 Frigate 以应用更改。"
"polygonAreaTooLarge": {
"title": "运动遮罩的大小达到了摄像头画面的{{polygonArea}}%。不建议设置太大的运动遮罩。",
"tips": "运动遮罩不会阻止检测到对象,你应该使用区域来限制检测对象。",
"documentation": "阅读文档(英文)"
},
"toast": {
"success": {
"title": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。",
"noName": "运动遮罩已保存。请重启 Frigate 以应用更改。"
}
}
},
"objectMasks": {
"label": "对象遮罩",
"documentTitle": "编辑对象遮罩 - Frigate",
"desc": "对象过滤器用于防止特定位置的指定对象被误报。",
"documentation": "文档(英文)",
"desc": {
"title": "对象过滤器用于防止特定位置的指定对象被误报。",
"documentation": "文档(英文)"
},
"add": "添加对象遮罩",
"edit": "编辑对象遮罩",
"context": "对象过滤器用于防止特定位置的指定对象被误报。",
"point_one": "{{count}} 点",
"point_other": "{{count}} 点",
"clickDrawPolygon": "在图像上点击添加点绘制多边形区域。",
"objects": "对象",
"objects.desc": "将应用于此对象遮罩的对象列表。",
"objects.allObjectTypes": "所有对象类型",
"toast.success": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。",
"toast.success.noName": "对象遮罩已保存。请重启 Frigate 以应用更改。"
"objects": {
"title": "对象",
"desc": "将应用于此对象遮罩的对象列表。",
"allObjectTypes": "所有对象类型"
},
"toast": {
"success": {
"title": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。",
"noName": "对象遮罩已保存。请重启 Frigate 以应用更改。"
}
}
}
},
"motionDetectionTuner": {
"title": "运动检测调整器",
"desc": "Frigate 将使用运动检测作为首个步骤,以确认一帧画面中是否有对象需要使用对象检测。",
"desc.documentation": "阅读有关运动检测的文档(英文)",
"Threshold": "阈值",
"Threshold.desc": "阈值决定像素亮度高于多少时会被认为是运动。<em>默认值30</em>",
"contourArea": "轮廓面积",
"contourArea.desc": "轮廓面积决定哪些变化的像素组符合运动条件。<em>默认值10</em>",
"improveContrast": "提高对比度",
"improveContrast.desc": "提高较暗场景的对比度。默认值:开启",
"desc": {
"title": "Frigate 将使用运动检测作为首个步骤,以确认一帧画面中是否有对象需要使用对象检测。",
"documentation": "阅读有关运动检测的文档(英文)"
},
"Threshold": {
"title": "阈值",
"desc": "阈值决定像素亮度高于多少时会被认为是运动。<em>默认值30</em>"
},
"contourArea": {
"title": "轮廓面积",
"desc": "轮廓面积决定哪些变化的像素组符合运动条件。<em>默认值10</em>"
},
"improveContrast": {
"title": "提高对比度",
"desc": "提高较暗场景的对比度。<em>默认值:启用</em>"
},
"toast": {
"success": "运动设置已保存。"
}
@ -310,8 +376,10 @@
},
"users": {
"title": "用户",
"management": "用户管理",
"management.desc": "管理此 Frigate 实例的用户账户。",
"management": {
"title": "用户管理",
"desc": "管理此 Frigate 实例的用户账户。"
},
"addUser": "添加用户",
"updatePassword": "修改密码",
"toast": {
@ -339,30 +407,40 @@
},
"dialog": {
"form": {
"user": "用户名",
"user.desc": "仅允许使用字母、数字、句点和下划线。",
"user.placeholder": "请输入用户名",
"password": "密码",
"password.placeholder": "请输入密码",
"password.confirm": "确认密码",
"password.confirm.placeholder": "请再次输入密码",
"password.strength": "密码强度:",
"password.strength.weak": "弱",
"password.strength.medium": "中等",
"password.strength.strong": "强",
"password.strength.veryStrong": "非常强",
"password.match": "密码匹配",
"password.notMatch": "密码不匹配",
"newPassword": "新密码",
"newPassword.placeholder": "请输入新密码",
"newPassword.confirm.placeholder": "请再次输入新密码",
"user": {
"title": "用户名",
"desc": "仅允许使用字母、数字、句点和下划线。",
"placeholder": "请输入用户名"
},
"password": {
"title": "密码",
"placeholder": "请输入密码",
"confirm": {
"title": "确认密码",
"placeholder": "请再次输入密码"
},
"strength": {
"title": "密码强度:",
"weak": "弱",
"medium": "中等",
"strong": "强",
"veryStrong": "非常强"
},
"match": "密码匹配",
"notMatch": "密码不匹配"
},
"newPassword": {
"title": "新密码",
"placeholder": "请输入新密码",
"confirm": {
"placeholder": "请再次输入新密码"
}
},
"usernameIsRequired": "用户名为必填项"
},
"createUser": {
"title": "创建新用户",
"desc": "创建一个新用户账户,并指定一个角色以控制访问 Frigate UI 的权限。",
"user": "用户",
"password": "密码",
"usernameOnlyInclude": "用户名只能包含字母、数字和 _"
},
"deleteUser": {
@ -398,12 +476,16 @@
"desc": "网页推送通知需要安全连接(<code>https://...</code>)。这是浏览器的限制。请通过安全方式访问 Frigate 以使用通知功能。",
"documentation": "阅读文档(英文)"
},
"email": "电子邮箱",
"email.placeholder": "例如example@email.com",
"email.desc": "需要输入有效的电子邮件,在推送服务出现问题时,将使用此电子邮件进行通知。",
"cameras": "摄像头",
"cameras.noCameras": "没有可用的摄像头",
"cameras.desc": "选择要启用通知的摄像头。",
"email": {
"title": "电子邮箱",
"placeholder": "例如example@email.com",
"desc": "需要输入有效的电子邮件,在推送服务出现问题时,将使用此电子邮件进行通知。"
},
"cameras": {
"title": "摄像头",
"noCameras": "没有可用的摄像头",
"desc": "选择要启用通知的摄像头。"
},
"deviceSpecific": "设备专用设置",
"registerDevice": "注册该设备",
"unregisterDevice": "取消注册该设备",
@ -429,5 +511,9 @@
"registerFailed": "通知注册失败。"
}
}
},
"cameraSetting": {
"camera": "相机",
"noCamera": "没有相机"
}
}

View File

@ -63,8 +63,12 @@
"cudaComputerCapability": "CUDA计算能力{{cuda_compute}}",
"vbios": "VBios信息{{vbios}}"
},
"closeInfo.label": "关闭GPU信息",
"copyInfo.label": "复制GPU信息",
"closeInfo": {
"label": "关闭GPU信息"
},
"copyInfo": {
"label": "复制GPU信息"
},
"toast": {
"success": "已复制GPU信息到剪贴板"
}
@ -87,12 +91,14 @@
"cameraStorage": {
"title": "摄像头存储",
"camera": "摄像头",
"unused": "未使用",
"unusedStorageInformation": "未使用存储信息",
"storageUsed": "存储使用",
"percentageOfTotalUsed": "总使用率",
"bandwidth": "带宽",
"unused.tips": "如果您的驱动器上存储了除 Frigate 录制内容之外的其他文件,该值可能无法准确反映 Frigate 可用的剩余空间。Frigate 不会追踪录制内容以外的存储使用情况。"
"unused": {
"title": "未使用",
"tips": "如果您的驱动器上存储了除 Frigate 录制内容之外的其他文件,该值可能无法准确反映 Frigate 可用的剩余空间。Frigate 不会追踪录制内容以外的存储使用情况。"
}
}
},
"cameras": {
@ -139,6 +145,12 @@
"reindexingEmbeddings": "正在重新索引嵌入(已完成 {{processed}}%"
},
"features": {
"title": "功能"
"title": "功能",
"embeddings": {
"image_embedding_speed": "图像特征提取速度",
"face_embedding_speed": "人脸特征提取速度",
"plate_recognition_speed": "车牌识别速度",
"text_embedding_speed": "文本编码速度"
}
}
}

View File

@ -293,7 +293,9 @@ export default function ReviewCard({
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
<Trans ns="components/dialog">recording.confirmDelete.desc</Trans>
<Trans ns="components/dialog">
recording.confirmDelete.desc.selected
</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>

View File

@ -33,7 +33,7 @@ export default function SearchThumbnailFooter({
searchResult.start_time,
config?.ui.time_format == "24hour"
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
: t("time.formattedTimestampExcludeSeconds", { ns: "common" }),
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
config?.ui.timezone,
);

View File

@ -41,7 +41,7 @@ const timeAgo = ({
const elapsed: number = elapsedTime / 1000;
if (elapsed < 10) {
return t("time.justNow");
return t("time.justNow", { ns: "common" });
}
for (let i = 0; i < timeUnits.length; i++) {
@ -66,6 +66,7 @@ const timeAgo = ({
if (monthDiff > 0) {
const unitAmount = monthDiff;
return t("time.ago", {
ns: "common",
timeAgo: t(`time.${dense ? timeUnits[i].unit : timeUnits[i].full}`, {
time: unitAmount,
}),
@ -74,6 +75,7 @@ const timeAgo = ({
} else if (elapsed >= timeUnits[i].value) {
const unitAmount: number = Math.floor(elapsed / timeUnits[i].value);
return t("time.ago", {
ns: "common",
timeAgo: t(`time.${dense ? timeUnits[i].unit : timeUnits[i].full}`, {
time: unitAmount,
}),

View File

@ -28,7 +28,7 @@ export default function CalendarFilterButton({
day,
updateSelectedDay,
}: CalendarFilterButtonProps) {
const { t } = useTranslation(["components/filter"]);
const { t } = useTranslation(["components/filter", "views/events"]);
const [open, setOpen] = useState(false);
const selectedDate = useFormattedTimestamp(
day == undefined ? 0 : day?.getTime() / 1000 + 1,
@ -38,7 +38,7 @@ export default function CalendarFilterButton({
const trigger = (
<Button
className="flex items-center gap-2"
aria-label={t("date.selectDateBy.label")}
aria-label={t("explore.date.selectDateBy.label")}
variant={day == undefined ? "default" : "select"}
size="sm"
>
@ -109,7 +109,7 @@ export function CalendarRangeFilterButton({
const trigger = (
<Button
className="flex items-center gap-2"
aria-label={t("date.selectDateBy.label")}
aria-label={t("explore.date.selectDateBy.label")}
variant={range == undefined ? "default" : "select"}
size="sm"
>

View File

@ -279,7 +279,7 @@ function NewGroupDialog({
setOpen(false);
setEditState("none");
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
@ -296,9 +296,12 @@ function NewGroupDialog({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
@ -485,9 +488,7 @@ export function EditGroupDialog({
<div className="scrollbar-container flex flex-col overflow-y-auto md:my-4">
<Header className="mt-2" onClose={() => setOpen(false)}>
<Title>{t("group.edit")}</Title>
<Description className="sr-only">
{t("group.edit.desc")}
</Description>
<Description className="sr-only">{t("group.edit")}</Description>
</Header>
<CameraGroupEdit
@ -538,7 +539,9 @@ export function CameraGroupRow({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("group.delete.confirm")}</AlertDialogTitle>
<AlertDialogTitle>
{t("group.delete.confirm.title")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
<Trans ns="components/camera" values={{ name: group[0] }}>
@ -734,7 +737,7 @@ export function CameraGroupEdit({
.then(async (res) => {
if (res.status === 200) {
toast.success(
t("group.toast.success", {
t("group.success", {
name: values.name,
}),
{
@ -748,7 +751,7 @@ export function CameraGroupEdit({
setAllGroupsStreamingSettings(updatedSettings);
} else {
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
@ -764,7 +767,7 @@ export function CameraGroupEdit({
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage,
ns: "common",
}),

View File

@ -38,17 +38,17 @@ export function CamerasFilterButton({
const buttonText = useMemo(() => {
if (isMobile) {
return t("menu.live.cameras", { ns: "common" });
return t("menu.live.cameras.title", { ns: "common" });
}
if (!selectedCameras || selectedCameras.length == 0) {
return t("menu.live.allCameras", { ns: "common" });
}
return t("menu.live.cameras.count", {
ns: "common",
count: selectedCameras.includes("birdseye")
? selectedCameras.length - 1
: selectedCameras.length,
ns: "common",
});
}, [selectedCameras, t]);
@ -158,7 +158,7 @@ export function CamerasFilterContent({
<div className="scrollbar-container flex h-auto max-h-[80dvh] flex-col gap-2 overflow-y-auto overflow-x-hidden p-4">
<FilterSwitch
isChecked={currentCameras == undefined}
label={t("cameras.all")}
label={t("cameras.all.title")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentCameras(undefined);

View File

@ -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")}</div>
<div className="text-md">{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")}

View File

@ -442,7 +442,7 @@ export function GeneralFilterContent({
onReset,
onClose,
}: GeneralFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
const { t } = useTranslation(["components/filter", "views/events"]);
return (
<>
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
@ -476,7 +476,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
{t("labels.all")}
{t("labels.all.title")}
</Label>
<Switch
className="ml-1"
@ -523,7 +523,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allZones"
>
{t("zones.all")}
{t("zones.all.title")}
</Label>
<Switch
className="ml-1"
@ -600,7 +600,7 @@ function ShowMotionOnlyButton({
motionOnly,
setMotionOnly,
}: ShowMotionOnlyButtonProps) {
const { t } = useTranslation(["views/events"]);
const { t } = useTranslation(["views/events", "components/filter"]);
const [motionOnlyButton, setMotionOnlyButton] = useOptimisticState(
motionOnly,
setMotionOnly,
@ -627,7 +627,7 @@ function ShowMotionOnlyButton({
<Button
size="sm"
className="duration-0"
aria-label={t("motion.showMotionOnly", { ns: "components/filter" })}
aria-label={t("motion.only")}
variant={motionOnlyButton ? "select" : "default"}
onClick={() => setMotionOnlyButton(!motionOnlyButton)}
>

View File

@ -27,7 +27,7 @@ export default function SearchActionGroup({
setSelectedObjects,
pullLatestData,
}: SearchActionGroupProps) {
const { t } = useTranslation(["views/filter"]);
const { t } = useTranslation(["components/filter"]);
const onClearSelected = useCallback(() => {
setSelectedObjects([]);
}, [setSelectedObjects]);

View File

@ -198,7 +198,7 @@ export default function SearchFilterGroup({
to: new Date(filter.before * 1000),
}
}
defaultText={isMobile ? t("dates.all.short") : t("dates.all")}
defaultText={isMobile ? t("dates.all.short") : t("dates.all.title")}
updateSelectedRange={onUpdateSelectedRange}
/>
)}
@ -244,7 +244,7 @@ function GeneralFilterButton({
}
if (!selectedLabels || selectedLabels.length == 0) {
return t("labels.all");
return t("labels.all.title");
}
if (selectedLabels.length == 1) {
@ -338,7 +338,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
{t("labels.all")}
{t("labels.all.title")}
</Label>
<Switch
className="ml-1"

View File

@ -78,7 +78,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
{t("labels.all")}
{t("labels.all.title")}
</Label>
<Switch
className="ml-1"

View File

@ -198,7 +198,7 @@ export function CombinedStorageGraph({
style={{ backgroundColor: item.color }}
></div>
{item.name === "Unused"
? t("storage.cameraStorage.unused")
? t("storage.cameraStorage.unused.title")
: item.name.replaceAll("_", " ")}
{item.name === "Unused" && (
<Popover>

View File

@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
type StepIndicatorProps = {
steps: string[];
currentStep: number;
};
export default function StepIndicator({
steps,
currentStep,
}: StepIndicatorProps) {
return (
<div className="flex flex-row justify-evenly">
{steps.map((name, idx) => (
<div className="flex flex-col items-center gap-2">
<div
className={cn(
"flex size-16 items-center justify-center rounded-full",
currentStep == idx ? "bg-selected" : "border-2 border-selected",
)}
>
{idx + 1}
</div>
<div className="w-24 text-center md:w-24">{name}</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,58 @@
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import React, { useCallback } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
type ImageEntryProps = {
onSave: (file: File) => void;
children?: React.ReactNode;
};
export default function ImageEntry({ onSave, children }: ImageEntryProps) {
const formSchema = z.object({
file: z.instanceof(FileList, { message: "Please select an image file." }),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
});
const fileRef = form.register("file");
// upload handler
const onSubmit = useCallback(
(data: z.infer<typeof formSchema>) => {
if (!data["file"] || Object.keys(data.file).length == 0) {
return;
}
onSave(data["file"]["0"]);
},
[onSave],
);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="file"
render={() => (
<FormItem>
<FormControl>
<Input
className="aspect-video h-40 w-full"
type="file"
{...fileRef}
/>
</FormControl>
</FormItem>
)}
/>
{children}
</form>
</Form>
);
}

View File

@ -248,7 +248,7 @@ export default function InputWithTags({
filters.before &&
timestamp >= filters.before * 1000
) {
toast.error(t("afterDatebeEarlierBefore"), {
toast.error(t("filter.toast.error.afterDatebeEarlierBefore"), {
position: "top-center",
});
return;
@ -727,7 +727,7 @@ export default function InputWithTags({
<div className="space-y-2">
<h3 className="font-medium">{t("filter.tips.title")}</h3>
<p className="text-sm text-muted-foreground">
{t("filter.tips.desc")}
{t("filter.tips.desc.text")}
</p>
<Trans
ns="views/search"

View File

@ -0,0 +1,68 @@
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import React, { useCallback } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
type TextEntryProps = {
defaultValue?: string;
placeholder?: string;
allowEmpty?: boolean;
onSave: (text: string) => void;
children?: React.ReactNode;
};
export default function TextEntry({
defaultValue,
placeholder,
allowEmpty,
onSave,
children,
}: TextEntryProps) {
const formSchema = z.object({
text: z.string(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { text: defaultValue },
});
const fileRef = form.register("text");
// upload handler
const onSubmit = useCallback(
(data: z.infer<typeof formSchema>) => {
if (!allowEmpty && !data["text"]) {
return;
}
onSave(data["text"]);
},
[onSave, allowEmpty],
);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="text"
render={() => (
<FormItem>
<FormControl>
<Input
className="aspect-video h-8 w-full"
placeholder={placeholder}
type="text"
{...fileRef}
/>
</FormControl>
</FormItem>
)}
/>
{children}
</form>
</Form>
);
}

View File

@ -15,9 +15,13 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { DialogClose } from "../ui/dialog";
} from "@/components/ui/dropdown-menu";
import {
Drawer,
DrawerContent,
DrawerTrigger,
DrawerClose,
} from "@/components/ui/drawer";
import { LuLogOut, LuSquarePen } from "react-icons/lu";
import useSWR from "swr";
@ -32,7 +36,7 @@ type AccountSettingsProps = {
};
export default function AccountSettings({ className }: AccountSettingsProps) {
const { t } = useTranslation(["views/settings"]);
const { t } = useTranslation(["views/settings", "common"]);
const { data: profile } = useSWR("profile");
const { data: config } = useSWR("config");
const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
@ -42,7 +46,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
const Container = isDesktop ? DropdownMenu : Drawer;
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
const MenuItem = isDesktop ? DropdownMenuItem : DialogClose;
const MenuItem = isDesktop ? DropdownMenuItem : DrawerClose;
const handlePasswordSave = async (password: string) => {
if (!profile?.username || profile.username === "anonymous") return;
@ -74,8 +78,8 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
return (
<Container modal={!isDesktop}>
<Trigger>
<Tooltip>
<Tooltip>
<Trigger asChild>
<TooltipTrigger asChild>
<div
className={cn(
@ -89,34 +93,41 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
<VscAccount className="size-5 md:m-[6px]" />
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right">
<p>{t("menu.user.account", { ns: "common" })}</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>
</Trigger>
</Trigger>
<TooltipPortal>
<TooltipContent side="right" sideOffset={5}>
<p>{t("menu.user.account", { ns: "common" })}</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>
<Content
className={
isDesktop ? "mr-5 w-72" : "max-h-[75dvh] overflow-hidden p-2"
}
className={cn(
isDesktop ? "mr-5 w-72" : "max-h-[75dvh] overflow-hidden p-4",
)}
>
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
<DropdownMenuLabel>
{t("menu.user.current", {
ns: "common",
user:
profile?.username || t("menu.user.anonymous", { ns: "common" }),
})}{" "}
{t("role." + profile?.role) &&
`(${t("role." + profile?.role, { ns: "common" })})`}
<DropdownMenuLabel className="flex flex-col gap-1.5">
<div>
{t("menu.user.current", {
ns: "common",
user:
profile?.username ||
t("menu.user.anonymous", { ns: "common" }),
})}{" "}
{t("role." + profile?.role) &&
`(${t("role." + profile?.role, { ns: "common" })})`}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
<DropdownMenuSeparator className={isDesktop ? "my-2" : "my-2"} />
{profile?.username && profile.username !== "anonymous" && (
<MenuItem
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
className={cn(
"flex w-full items-center gap-2",
isDesktop ? "cursor-pointer" : "p-2 text-sm",
)}
aria-label={t("menu.user.setPassword", { ns: "common" })}
onClick={() => setPasswordDialogOpen(true)}
>
@ -124,13 +135,16 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
</MenuItem>
)}
<MenuItem
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
className={cn(
"flex w-full items-center gap-2",
isDesktop ? "cursor-pointer" : "p-2 text-sm",
)}
asChild
aria-label={t("menu.user.logout", { ns: "common" })}
>
<a className="flex" href={logoutUrl}>
<a href={logoutUrl} className="flex items-center gap-2">
<LuLogOut className="mr-2 size-4" />
<span>{t("menu.user.logout", { ns: "common" })}</span>
</a>

View File

@ -68,7 +68,7 @@ type GeneralSettingsProps = {
};
export default function GeneralSettings({ className }: GeneralSettingsProps) {
const { t } = useTranslation(["common"]);
const { t } = useTranslation(["common", "views/settings"]);
const { data: profile } = useSWR("profile");
const { data: config } = useSWR<FrigateConfig>("config");
const logoutUrl = config?.proxy?.logout_url || "/api/logout";
@ -100,7 +100,9 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
if (response.status === 200) {
setPasswordDialogOpen(false);
toast.success(
t("users.toast.success.updatePassword", { ns: "views/settings" }),
t("users.toast.success.updatePassword", {
ns: "views/settings",
}),
{
position: "top-center",
},
@ -184,11 +186,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label={t("menu.user.setPassword")}
aria-label={t("menu.user.setPassword", { ns: "common" })}
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>{t("menu.user.setPassword")}</span>
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
</MenuItem>
)}
<MenuItem
@ -501,7 +503,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
aria-label={t("menu.documentation.label")}
>
<LuLifeBuoy className="mr-2 size-4" />
<span>{t("menu.documentation")}</span>
<span>{t("menu.documentation.title")}</span>
</MenuItem>
</a>
<a

View File

@ -246,7 +246,7 @@ export default function LiveContextMenu({
strftime_fmt:
config?.ui.time_format == "24hour"
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
: t("time.formattedTimestampExcludeSeconds", { ns: "common" }),
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
});
return t("time.untilForTime", { ns: "common", time });
};
@ -343,7 +343,9 @@ export default function LiveContextMenu({
}
>
<div className="text-primary">
{t("streaming.debugView", { ns: "components/dialog" })}
{t("streaming.debugView", {
ns: "components/dialog",
})}
</div>
</div>
</ContextMenuItem>

View File

@ -149,7 +149,7 @@ export default function SearchResultActions({
onClick={showSnapshot}
>
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
<span>{t("itemMenu.submitToPlus")}</span>
<span>{t("itemMenu.submitToPlus.label")}</span>
</MenuItem>
)}
<MenuItem
@ -170,7 +170,9 @@ export default function SearchResultActions({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("dialog.confirmDelete")}</AlertDialogTitle>
<AlertDialogTitle>
{t("dialog.confirmDelete.title")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
<Trans ns="views/explore">dialog.confirmDelete.desc</Trans>

View File

@ -128,7 +128,7 @@ export default function CreateUserDialog({
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t("users.dialog.form.user")}
{t("users.dialog.form.user.title")}
</FormLabel>
<FormControl>
<Input
@ -150,7 +150,7 @@ export default function CreateUserDialog({
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t("users.dialog.form.password")}
{t("users.dialog.form.password.title")}
</FormLabel>
<FormControl>
<Input
@ -170,7 +170,7 @@ export default function CreateUserDialog({
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t("users.dialog.form.password.confirm")}
{t("users.dialog.form.password.confirm.title")}
</FormLabel>
<FormControl>
<Input

View File

@ -267,7 +267,7 @@ export function ExportContent({
{isDesktop && (
<>
<DialogHeader>
<DialogTitle>{t("menu.export")}</DialogTitle>
<DialogTitle>{t("menu.export", { ns: "common" })}</DialogTitle>
</DialogHeader>
<SelectSeparator className="my-4 bg-secondary" />
</>
@ -404,14 +404,14 @@ function CustomTimeSelector({
const formattedStart = useFormattedTimestamp(
startTime,
config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour")
: t("time.formattedTimestamp"),
? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }),
);
const formattedEnd = useFormattedTimestamp(
endTime,
config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour")
: t("time.formattedTimestamp"),
? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }),
);
const startClock = useMemo(() => {
@ -444,7 +444,7 @@ function CustomTimeSelector({
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label={t("export.time.start")}
aria-label={t("export.time.start.title")}
variant={startOpen ? "select" : "default"}
size="sm"
onClick={() => {
@ -510,7 +510,7 @@ function CustomTimeSelector({
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label={t("export.time.end")}
aria-label={t("export.time.end.title")}
variant={endOpen ? "select" : "default"}
size="sm"
onClick={() => {

View File

@ -27,7 +27,7 @@ export default function MobileCameraDrawer({
<DrawerTrigger asChild>
<Button
className="rounded-lg capitalize"
aria-label={t("menu.live.cameras")}
aria-label={t("menu.live.cameras.title")}
size="sm"
>
<FaVideo className="text-secondary-foreground" />

View File

@ -19,7 +19,6 @@ import { toast } from "sonner";
import axios from "axios";
import SaveExportOverlay from "./SaveExportOverlay";
import { isIOS, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
type DrawerMode = "none" | "select" | "export" | "calendar" | "filter";
@ -70,7 +69,7 @@ export default function MobileReviewSettingsDrawer({
setMode,
setShowExportPreview,
}: MobileReviewSettingsDrawerProps) {
const { t } = useTranslation(["views/recording"]);
const { t } = useTranslation(["views/recording", "components/dialog"]);
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
// exports

View File

@ -68,13 +68,13 @@ export default function RoleChangeDialog({
<SelectItem value="admin" className="flex items-center gap-2">
<div className="flex items-center gap-2">
<LuShield className="size-4 text-primary" />
<span>{t("role.admin")}</span>
<span>{t("role.admin", { ns: "common" })}</span>
</div>
</SelectItem>
<SelectItem value="viewer" className="flex items-center gap-2">
<div className="flex items-center gap-2">
<LuUser className="size-4 text-primary" />
<span>{t("role.viewer")}</span>
<span>{t("role.viewer", { ns: "common" })}</span>
</div>
</SelectItem>
</SelectContent>

View File

@ -117,7 +117,7 @@ export default function SetPasswordDialog({
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="password">
{t("users.dialog.form.newPassword")}
{t("users.dialog.form.newPassword.title")}
</Label>
<Input
id="password"
@ -142,7 +142,7 @@ export default function SetPasswordDialog({
/>
</div>
<p className="text-xs text-muted-foreground">
{t("users.dialog.form.password.strength")}
{t("users.dialog.form.password.strength.title")}
<span className="font-medium">{getStrengthLabel()}</span>
</p>
</div>
@ -151,7 +151,7 @@ export default function SetPasswordDialog({
<div className="space-y-2">
<Label htmlFor="confirm-password">
{t("users.dialog.form.password.confirm")}
{t("users.dialog.form.password.confirm.title")}
</Label>
<Input
id="confirm-password"

View File

@ -85,7 +85,7 @@ export function AnnotationSettingsPane({
updateConfig();
} else {
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
@ -100,9 +100,12 @@ export function AnnotationSettingsPane({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
@ -145,7 +148,7 @@ export function AnnotationSettingsPane({
onCheckedChange={setShowZones}
/>
<Label className="cursor-pointer" htmlFor="show-zones">
{t("objectLifecycle.annotationSettings.showAllZones")}
{t("objectLifecycle.annotationSettings.showAllZones.title")}
</Label>
</div>
<div className="text-sm text-muted-foreground">

View File

@ -0,0 +1,168 @@
import StepIndicator from "@/components/indicators/StepIndicator";
import ImageEntry from "@/components/input/ImageEntry";
import TextEntry from "@/components/input/TextEntry";
import {
MobilePage,
MobilePageContent,
MobilePageDescription,
MobilePageHeader,
MobilePageTitle,
} from "@/components/mobile/MobilePage";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import axios from "axios";
import { useCallback, useState } from "react";
import { isDesktop } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
import { Link } from "react-router-dom";
import { toast } from "sonner";
const STEPS = ["Enter Face Name", "Upload Face Image", "Next Steps"];
type CreateFaceWizardDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
onFinish: () => void;
};
export default function CreateFaceWizardDialog({
open,
setOpen,
onFinish,
}: CreateFaceWizardDialogProps) {
const { t } = useTranslation("views/faceLibrary");
// wizard
const [step, setStep] = useState(0);
const [name, setName] = useState("");
const handleReset = useCallback(() => {
setStep(0);
setName("");
setOpen(false);
}, [setOpen]);
// data handling
const onUploadImage = useCallback(
(file: File) => {
const formData = new FormData();
formData.append("file", file);
axios
.post(`faces/${name}/register`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((resp) => {
if (resp.status == 200) {
setStep(2);
toast.success(t("toast.success.uploadedImage"), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.uploadingImageFailed", { errorMessage }), {
position: "top-center",
});
});
},
[name, t],
);
// layout
const Overlay = isDesktop ? Dialog : MobilePage;
const Content = isDesktop ? DialogContent : MobilePageContent;
const Header = isDesktop ? DialogHeader : MobilePageHeader;
const Title = isDesktop ? DialogTitle : MobilePageTitle;
const Description = isDesktop ? DialogDescription : MobilePageDescription;
return (
<Overlay
open={open}
onOpenChange={(open) => {
if (!open) {
handleReset();
}
}}
>
<Content
className={cn("flex flex-col gap-4", isDesktop ? "max-w-[50%]" : "p-4")}
>
<Header>
<Title>{t("button.addFace")}</Title>
{isDesktop && <Description>{t("description.addFace")}</Description>}
</Header>
<StepIndicator steps={STEPS} currentStep={step} />
{step == 0 && (
<TextEntry
placeholder="Enter Face Name"
onSave={(name) => {
setName(name);
setStep(1);
}}
>
<div className="flex justify-end py-2">
<Button variant="select" type="submit">
{t("button.next", { ns: "common" })}
</Button>
</div>
</TextEntry>
)}
{step == 1 && (
<ImageEntry onSave={onUploadImage}>
<div className="flex justify-end py-2">
<Button variant="select" type="submit">
{t("button.next", { ns: "common" })}
</Button>
</div>
</ImageEntry>
)}
{step == 2 && (
<div>
{t("toast.success.addFaceLibrary", { name })}
<p className="py-4 text-sm text-secondary-foreground">
{t("createFaceLibrary.nextSteps")}
</p>
<div className="text-s my-4 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/face_recognition"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocs")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
<div className="flex justify-end">
<Button
variant="select"
onClick={() => {
onFinish();
handleReset();
}}
>
{t("button.done", { ns: "common" })}
</Button>
</div>
</div>
)}
</Content>
</Overlay>
);
}

View File

@ -573,7 +573,7 @@ export default function ObjectLifecycle({
? t("time.formattedTimestamp2.24hour", {
ns: "common",
})
: t("time.formattedTimestamp2", {
: t("time.formattedTimestamp2.12hour", {
ns: "common",
}),
time_style: "medium",

View File

@ -98,7 +98,7 @@ export default function ReviewDetailDialog({
review?.start_time ?? 0,
config?.ui.time_format == "24hour"
? t("time.formattedTimestampWithYear.24hour", { ns: "common" })
: t("time.formattedTimestampWithYear", { ns: "common" }),
: t("time.formattedTimestampWithYear.12hour", { ns: "common" }),
config?.ui.timezone,
);

View File

@ -57,6 +57,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
@ -69,11 +70,12 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { LuInfo } from "react-icons/lu";
import { LuInfo, LuSearch } from "react-icons/lu";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { FaPencilAlt } from "react-icons/fa";
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import { useTranslation } from "react-i18next";
import { TbFaceId } from "react-icons/tb";
const SEARCH_TABS = [
"details",
@ -99,7 +101,7 @@ export default function SearchDetailDialog({
setSimilarity,
setInputFocused,
}: SearchDetailDialogProps) {
const { t } = useTranslation(["views/explore"]);
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@ -195,7 +197,9 @@ export default function SearchDetailDialog({
>
<Header>
<Title>{t("trackedObjectDetails")}</Title>
<Description className="sr-only">{t("details")}</Description>
<Description className="sr-only">
{t("trackedObjectDetails")}
</Description>
</Header>
<ScrollArea
className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
@ -311,7 +315,7 @@ function ObjectDetailsTab({
search?.start_time ?? 0,
config?.ui.time_format == "24hour"
? t("time.formattedTimestampWithYear.24hour", { ns: "common" })
: t("time.formattedTimestampWithYear", { ns: "common" }),
: t("time.formattedTimestampWithYear.12hour", { ns: "common" }),
config?.ui.timezone,
);
@ -553,6 +557,48 @@ function ObjectDetailsTab({
[search, apiHost, mutate, setSearch, t],
);
// face training
const hasFace = useMemo(() => {
if (!config?.face_recognition.enabled || !search) {
return false;
}
return search.data.attributes?.find((attr) => attr.label == "face");
}, [config, search]);
const { data: faceData } = useSWR(hasFace ? "faces" : null);
const faceNames = useMemo<string[]>(
() =>
faceData ? Object.keys(faceData).filter((face) => face != "train") : [],
[faceData],
);
const onTrainFace = useCallback(
(trainName: string) => {
axios
.post(`/faces/train/${trainName}/classify`, { event_id: search.id })
.then((resp) => {
if (resp.status == 200) {
toast.success(t("toast.success.trainedFace"), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.trainFailed", { errorMessage }), {
position: "top-center",
});
});
},
[search, t],
);
return (
<div className="flex flex-col gap-5">
<div className="flex w-full flex-row">
@ -561,7 +607,7 @@ function ObjectDetailsTab({
<div className="text-sm text-primary/40">{t("details.label")}</div>
<div className="flex flex-row items-center gap-2 text-sm capitalize">
{getIconForLabel(search.label, "size-4 text-primary")}
{t("{search.label}", { ns: "objects" })}
{t(search.label, { ns: "objects" })}
{search.sub_label && ` (${search.sub_label})`}
<Tooltip>
<TooltipTrigger asChild>
@ -575,7 +621,9 @@ function ObjectDetailsTab({
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("details.editSubLable")}</TooltipContent>
<TooltipContent>
{t("details.editSubLabel.title")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
@ -597,7 +645,7 @@ function ObjectDetailsTab({
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
<div className="flex flex-row items-center gap-1">
{t("details.topScore")}
{t("details.topScore.label")}
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
@ -669,20 +717,53 @@ function ObjectDetailsTab({
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
{config?.semantic_search.enabled && search.data.type == "object" && (
<Button
aria-label={t("itemMenu.findSimilar.aria")}
onClick={() => {
setSearch(undefined);
<div className="flex w-full flex-row gap-2">
{config?.semantic_search.enabled &&
search.data.type == "object" && (
<Button
className="w-full"
aria-label={t("itemMenu.findSimilar.aria")}
onClick={() => {
setSearch(undefined);
if (setSimilarity) {
setSimilarity();
}
}}
>
{t("itemMenu.findSimilar.label")}
</Button>
)}
if (setSimilarity) {
setSimilarity();
}
}}
>
<div className="flex gap-1">
<LuSearch />
{t("itemMenu.findSimilar.label")}
</div>
</Button>
)}
{hasFace && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="w-full">
<div className="flex gap-1">
<TbFaceId />
{t("trainFace", { ns: "views/faceLibrary" })}
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>
{t("trainFaceAs", { ns: "views/faceLibrary" })}
</DropdownMenuLabel>
{faceNames.map((faceName) => (
<DropdownMenuItem
key={faceName}
className="cursor-pointer capitalize"
onClick={() => onTrainFace(faceName)}
>
{faceName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-1.5">
@ -727,7 +808,7 @@ function ObjectDetailsTab({
aria-label={t("details.button.regenerate.label")}
onClick={() => regenerateDescription("thumbnails")}
>
{t("details.button.regenerate")}
{t("details.button.regenerate.title")}
</Button>
{search.has_snapshot && (
<DropdownMenu>
@ -772,13 +853,13 @@ function ObjectDetailsTab({
<TextEntryDialog
open={isSubLabelDialogOpen}
setOpen={setIsSubLabelDialogOpen}
title={t("details.editSubLable")}
title={t("details.editSubLabel.title")}
description={
search.label
? t("details.editSubLable.desc", {
label: t(search.label, { ns: "objects" }),
? t("details.editSubLabel.desc", {
label: t(search.label, { an: "objects" }),
})
: t("details.editSubLable.desc.noLabel")
: t("details.editSubLabel.descNoLabel")
}
onSave={handleSubLabelSave}
defaultValue={search?.sub_label || ""}
@ -921,10 +1002,10 @@ export function ObjectSnapshotTab({
}}
>
{/^[aeiou]/i.test(search?.label || "")
? t("explore.plus.review.true_other", {
? t("explore.plus.review.true.true_other", {
label: search?.label,
})
: t("explore.plus.review.true_one", {
: t("explore.plus.review.true.true_one", {
label: search?.label,
})}
</Button>
@ -938,10 +1019,10 @@ export function ObjectSnapshotTab({
}}
>
{/^[aeiou]/i.test(search?.label || "")
? t("explore.plus.review.false_other", {
? t("explore.plus.review.false.false_other", {
label: search?.label,
})
: t("explore.plus.review.false_one", {
: t("explore.plus.review.false.false_one", {
label: search?.label,
})}
</Button>

View File

@ -251,7 +251,7 @@ function TimeRangeFilterContent({
timeRange,
updateTimeRange,
}: TimeRangeFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
const { t } = useTranslation(["components/filter", "components/dialog"]);
const [startOpen, setStartOpen] = useState(false);
const [endOpen, setEndOpen] = useState(false);
@ -406,7 +406,7 @@ export function ZoneFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allZones"
>
{t("zones.all")}
{t("zones.all.title")}
</Label>
<Switch
className="ml-1"
@ -581,6 +581,7 @@ export function SpeedFilterContent({
<DropdownMenuSeparator className="mb-3" />
<div className="mb-3 text-lg">
{t("estimatedSpeed", {
ns: "components/filter",
unit:
config?.ui.unit_system == "metric"
? t("unit.speed.kph", { ns: "common" })

View File

@ -1,3 +1,4 @@
import TextEntry from "@/components/input/TextEntry";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -7,15 +8,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useEffect } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
type TextEntryDialogProps = {
open: boolean;
title: string;
@ -35,35 +29,7 @@ export default function TextEntryDialog({
defaultValue = "",
allowEmpty = false,
}: TextEntryDialogProps) {
const formSchema = z.object({
text: z.string(),
});
const { t } = useTranslation("components/dialog");
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { text: defaultValue },
});
const fileRef = form.register("text");
// upload handler
const onSubmit = useCallback(
(data: z.infer<typeof formSchema>) => {
if (!allowEmpty && !data["text"]) {
return;
}
onSave(data["text"]);
},
[onSave, allowEmpty],
);
useEffect(() => {
if (open) {
form.reset({ text: defaultValue });
}
}, [open, defaultValue, form]);
const { t } = useTranslation("common");
return (
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
@ -72,33 +38,20 @@ export default function TextEntryDialog({
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="text"
render={() => (
<FormItem>
<FormControl>
<Input
className="aspect-video h-8 w-full"
type="text"
{...fileRef}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter className="pt-4">
<Button type="button" onClick={() => setOpen(false)}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button variant="select" type="submit">
{t("button.save", { ns: "common" })}
</Button>
</DialogFooter>
</form>
</Form>
<TextEntry
defaultValue={defaultValue}
allowEmpty={allowEmpty}
onSave={onSave}
>
<DialogFooter className="pt-4">
<Button type="button" onClick={() => setOpen(false)}>
{t("button.cancel")}
</Button>
<Button variant="select" type="submit">
{t("button.save")}
</Button>
</DialogFooter>
</TextEntry>
</DialogContent>
</Dialog>
);

View File

@ -1,3 +1,4 @@
import ImageEntry from "@/components/input/ImageEntry";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -7,12 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useTranslation } from "react-i18next";
type UploadImageDialogProps = {
open: boolean;
@ -28,27 +24,7 @@ export default function UploadImageDialog({
setOpen,
onSave,
}: UploadImageDialogProps) {
const formSchema = z.object({
file: z.instanceof(FileList, { message: "Please select an image file." }),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
});
const fileRef = form.register("file");
// upload handler
const onSubmit = useCallback(
(data: z.infer<typeof formSchema>) => {
if (!data["file"] || Object.keys(data.file).length == 0) {
return;
}
onSave(data["file"]["0"]);
},
[onSave],
);
const { t } = useTranslation("common");
return (
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
@ -57,31 +33,14 @@ export default function UploadImageDialog({
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="file"
render={() => (
<FormItem>
<FormControl>
<Input
className="aspect-video h-40 w-full"
type="file"
{...fileRef}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter className="pt-4">
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="select" type="submit">
Save
</Button>
</DialogFooter>
</form>
</Form>
<ImageEntry onSave={onSave}>
<DialogFooter className="pt-4">
<Button onClick={() => setOpen(false)}>{t("button.cancel")}</Button>
<Button variant="select" type="submit">
{t("button.save")}
</Button>
</DialogFooter>
</ImageEntry>
</DialogContent>
</Dialog>
);

View File

@ -12,16 +12,16 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
const fullStatsContent = (
<>
<p>
<span className="text-white/70">{t("stats.streamType")}</span>{" "}
<span className="text-white/70">{t("stats.streamType.title")}</span>{" "}
<span className="text-white">{stats.streamType}</span>
</p>
<p>
<span className="text-white/70">{t("stats.bandwidth")}</span>{" "}
<span className="text-white/70">{t("stats.bandwidth.title")}</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kbps</span>
</p>
{stats.latency != undefined && (
<p>
<span className="text-white/70">{t("stats.latency")}</span>{" "}
<span className="text-white/70">{t("stats.latency.title")}</span>{" "}
<span
className={`text-white ${stats.latency > 2 ? "text-danger" : ""}`}
>
@ -35,7 +35,9 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
</p>
{stats.droppedFrames != undefined && (
<p>
<span className="text-white/70">{t("stats.droppedFrames")}</span>{" "}
<span className="text-white/70">
{t("stats.droppedFrames.title")}
</span>{" "}
<span className="text-white">{stats.droppedFrames}</span>
</p>
)}
@ -68,7 +70,9 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
</div>
{stats.latency != undefined && (
<div className="hidden flex-col items-center gap-1 md:flex">
<span className="text-white/70">{t("stats.latency.short")}</span>
<span className="text-white/70">
{t("stats.latency.short.title")}
</span>
<span
className={`text-white ${stats.latency >= 2 ? "text-danger" : ""}`}
>
@ -81,7 +85,7 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
{stats.droppedFrames != undefined && (
<div className="flex flex-col items-center justify-end gap-1">
<span className="text-white/70">
{t("stats.droppedFrames.short")}
{t("stats.droppedFrames.short.title")}
</span>
<span className="text-white">
{t("stats.droppedFrames.short.value", {

View File

@ -171,7 +171,7 @@ export default function PreviewThumbnailPlayer({
review.start_time,
config?.ui.time_format == "24hour"
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
: t("time.formattedTimestampExcludeSeconds", { ns: "common" }),
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
config?.ui?.timezone,
);

View File

@ -50,7 +50,7 @@ export function CameraStreamingDialog({
setIsDialogOpen,
onSave,
}: CameraStreamingDialogProps) {
const { t } = useTranslation(["components/camera"]);
const { t } = useTranslation(["components/camera", "components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [isLoading, setIsLoading] = useState(false);
@ -198,7 +198,9 @@ export function CameraStreamingDialog({
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("streaming.restreaming.desc", { ns: "components/dialog" })}
{t("streaming.restreaming.desc.title", {
ns: "components/dialog",
})}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@ -206,7 +208,7 @@ export function CameraStreamingDialog({
rel="noopener noreferrer"
className="inline"
>
{t("streaming.restreaming.readTheDocumentation", {
{t("streaming.restreaming.desc.readTheDocumentation", {
ns: "components/dialog",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
@ -257,7 +259,7 @@ export function CameraStreamingDialog({
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("group.camera.setting.audio.desc")}
{t("group.camera.setting.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@ -265,7 +267,7 @@ export function CameraStreamingDialog({
rel="noopener noreferrer"
className="inline"
>
{t("group.camera.setting.audio.desc.document")}
{t("group.camera.setting.audio.tips.document")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@ -322,7 +324,7 @@ export function CameraStreamingDialog({
<>
<p className="text-sm text-muted-foreground">
{t(
"group.camera.setting.streamMethod.method.continuousStreaming.desc",
"group.camera.setting.streamMethod.method.continuousStreaming.desc.title",
)}
</p>
<div className="flex items-center gap-2">

View File

@ -107,7 +107,7 @@ export default function MotionMaskEditPane({
polygon: z.object({ name: z.string(), isFinished: z.boolean() }),
})
.refine(() => polygon?.isFinished === true, {
message: t("masksAndZones.polygonDrawing.error.mustBeFinished"),
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"],
});
@ -166,7 +166,7 @@ export default function MotionMaskEditPane({
if (res.status === 200) {
toast.success(
polygon.name
? t("masksAndZones.motionMasks.toast.success", {
? t("masksAndZones.motionMasks.toast.success.title", {
polygonName: polygon.name,
})
: t("masksAndZones.motionMasks.toast.success.noName"),
@ -177,7 +177,7 @@ export default function MotionMaskEditPane({
updateConfig();
} else {
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
@ -192,9 +192,12 @@ export default function MotionMaskEditPane({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
@ -283,7 +286,7 @@ export default function MotionMaskEditPane({
{polygonArea && polygonArea >= 0.35 && (
<>
<div className="mb-3 text-sm text-danger">
{t("masksAndZones.motionMasks.polygonAreaTooLarge", {
{t("masksAndZones.motionMasks.polygonAreaTooLarge.title", {
polygonArea: Math.round(polygonArea * 100),
})}
</div>

View File

@ -109,7 +109,7 @@ export default function ObjectMaskEditPane({
polygon: z.object({ isFinished: z.boolean(), name: z.string() }),
})
.refine(() => polygon?.isFinished === true, {
message: t("masksAndZones.polygonDrawing.error.mustBeFinished"),
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"],
});
@ -198,7 +198,7 @@ export default function ObjectMaskEditPane({
if (res.status === 200) {
toast.success(
polygon.name
? t("masksAndZones.objectMasks.toast.success", {
? t("masksAndZones.objectMasks.toast.success.title", {
polygonName: polygon.name,
})
: t("masksAndZones.objectMasks.toast.success.noName"),
@ -209,7 +209,7 @@ export default function ObjectMaskEditPane({
updateConfig();
} else {
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
@ -225,7 +225,7 @@ export default function ObjectMaskEditPane({
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage,
ns: "common",
}),
@ -327,7 +327,7 @@ export default function ObjectMaskEditPane({
render={({ field }) => (
<FormItem>
<FormLabel>
{t("masksAndZones.objectMasks.objects")}
{t("masksAndZones.objectMasks.objects.title")}
</FormLabel>
<Select
onValueChange={field.onChange}

View File

@ -188,9 +188,9 @@ export default function PolygonItem({
updateConfig();
} else {
toast.error(
t("toast.save.error", {
errorMessage: res.statusText,
t("toast.save.error.title", {
ns: "common",
errorMessage: res.statusText,
}),
{
position: "top-center",
@ -203,9 +203,12 @@ export default function PolygonItem({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);

View File

@ -59,7 +59,9 @@ 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")}</div>
<div className="text-md">
{t("explore.settings.defaultView.title")}
</div>
<div className="space-y-1 text-xs text-muted-foreground">
{t("explore.settings.defaultView.desc")}
</div>
@ -95,7 +97,9 @@ 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")}</div>
<div className="text-md">
{t("explore.settings.gridColumns.title")}
</div>
<div className="space-y-1 text-xs text-muted-foreground">
{t("explore.settings.gridColumns.desc")}
</div>

View File

@ -161,7 +161,7 @@ export default function ZoneEditPane({
.optional()
.or(z.literal("")),
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
message: t("masksAndZones.polygonDrawing.error.mustBeFinished"),
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
}),
objects: z.array(z.string()).optional(),
review_alerts: z.boolean().default(false).optional(),
@ -170,28 +170,28 @@ export default function ZoneEditPane({
lineA: z.coerce
.number()
.min(0.1, {
message: t("masksAndZones.form.distance.error"),
message: t("masksAndZones.form.distance.error.text"),
})
.optional()
.or(z.literal("")),
lineB: z.coerce
.number()
.min(0.1, {
message: t("masksAndZones.form.distance.error"),
message: t("masksAndZones.form.distance.error.text"),
})
.optional()
.or(z.literal("")),
lineC: z.coerce
.number()
.min(0.1, {
message: t("masksAndZones.form.distance.error"),
message: t("masksAndZones.form.distance.error.text"),
})
.optional()
.or(z.literal("")),
lineD: z.coerce
.number()
.min(0.1, {
message: t("masksAndZones.form.distance.error"),
message: t("masksAndZones.form.distance.error.text"),
})
.optional()
.or(z.literal("")),
@ -422,7 +422,7 @@ export default function ZoneEditPane({
updateConfig();
} else {
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
@ -438,7 +438,7 @@ export default function ZoneEditPane({
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage,
ns: "common",
}),
@ -496,7 +496,7 @@ export default function ZoneEditPane({
: t("masksAndZones.zones.add")}
</Heading>
<div className="my-2 text-sm text-muted-foreground">
<p>{t("masksAndZones.zones.desc")}</p>
<p>{t("masksAndZones.zones.desc.title")}</p>
</div>
<Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && (
@ -532,7 +532,7 @@ export default function ZoneEditPane({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("masksAndZones.zones.name")}</FormLabel>
<FormLabel>{t("masksAndZones.zones.name.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]"
@ -553,7 +553,7 @@ export default function ZoneEditPane({
name="inertia"
render={({ field }) => (
<FormItem>
<FormLabel>{t("masksAndZones.zones.inertia")}</FormLabel>
<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]"
@ -576,7 +576,9 @@ export default function ZoneEditPane({
name="loitering_time"
render={({ field }) => (
<FormItem>
<FormLabel>{t("masksAndZones.zones.loiteringTime")}</FormLabel>
<FormLabel>
{t("masksAndZones.zones.loiteringTime.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]"
@ -595,7 +597,7 @@ export default function ZoneEditPane({
/>
<Separator className="my-2 flex bg-secondary" />
<FormItem>
<FormLabel>{t("masksAndZones.zones.objects")}</FormLabel>
<FormLabel>{t("masksAndZones.zones.objects.title")}</FormLabel>
<FormDescription>
{t("masksAndZones.zones.objects.desc")}
</FormDescription>
@ -631,7 +633,7 @@ export default function ZoneEditPane({
className="cursor-pointer text-primary"
htmlFor="allLabels"
>
{t("masksAndZones.zones.speedEstimation")}
{t("masksAndZones.zones.speedEstimation.title")}
</FormLabel>
<Switch
checked={field.value}
@ -644,7 +646,7 @@ export default function ZoneEditPane({
) {
toast.error(
t(
"masksAndZones.zones.speedEstimation.pointLengthError",
"masksAndZones.zones.speedThreshold.toast.error.pointLengthError",
),
);
return;
@ -655,7 +657,7 @@ export default function ZoneEditPane({
if (checked && loiteringTime && loiteringTime > 0) {
toast.error(
t(
"masksAndZones.zones.speedEstimation.loiteringTimeError",
"masksAndZones.zones.speedThreshold.toast.error.loiteringTimeError",
),
);
}
@ -778,11 +780,12 @@ export default function ZoneEditPane({
render={({ field }) => (
<FormItem>
<FormLabel>
{t("masksAndZones.zones.speedThreshold", {
{t("masksAndZones.zones.speedThreshold.title", {
ns: "views/settings",
unit:
config?.ui.unit_system == "imperial"
? t("unit.speed.mph")
: t("unit.speed.kph"),
? t("unit.speed.mph", { ns: "common" })
: t("unit.speed.kph", { ns: "common" }),
})}
</FormLabel>
<FormControl>

View File

@ -60,15 +60,15 @@ interface Preset {
// Define presets
const PRESETS: Preset[] = [
{ name: "today", label: t("time.today") },
{ name: "yesterday", label: t("time.yesterday") },
{ name: "last7", label: t("time.last7") },
{ name: "last14", label: t("time.last14") },
{ name: "last30", label: t("time.last30") },
{ name: "thisWeek", label: t("time.thisWeek") },
{ name: "lastWeek", label: t("time.lastWeek") },
{ name: "thisMonth", label: t("time.thisMonth") },
{ name: "lastMonth", label: t("time.lastMonth") },
{ name: "today", label: t("time.today", { ns: "common" }) },
{ name: "yesterday", label: t("time.yesterday", { ns: "common" }) },
{ name: "last7", label: t("time.last7", { ns: "common" }) },
{ name: "last14", label: t("time.last14", { ns: "common" }) },
{ name: "last30", label: t("time.last30", { ns: "common" }) },
{ name: "thisWeek", label: t("time.thisWeek", { ns: "common" }) },
{ name: "lastWeek", label: t("time.lastWeek", { ns: "common" }) },
{ name: "thisMonth", label: t("time.thisMonth", { ns: "common" }) },
{ name: "lastMonth", label: t("time.lastMonth", { ns: "common" }) },
];
/** The DateRangePicker component allows a user to select a range of dates */

View File

@ -8,7 +8,7 @@ import { t } from "i18next";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label={t("pagination.label")}
aria-label={t("pagination.label", { ns: "common" })}
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
@ -65,13 +65,13 @@ const PaginationPrevious = ({
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label={t("pagination.previous.label")}
aria-label={t("pagination.previous.label", { ns: "common" })}
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>{t("pagination.previous")}</span>
<span>{t("pagination.previous.title", { ns: "common" })}</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
@ -81,12 +81,12 @@ const PaginationNext = ({
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label={t("pagination.next.label")}
aria-label={t("pagination.next.label", { ns: "common" })}
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>{t("pagination.next")}</span>
<span>{t("pagination.next.title", { ns: "common" })}</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
@ -102,7 +102,7 @@ const PaginationEllipsis = ({
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">{t("pagination.more")}</span>
<span className="sr-only">{t("pagination.more", { ns: "common" })}</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";

View File

@ -31,7 +31,7 @@ export default function useNavigation(
id: ID_LIVE,
variant,
icon: FaVideo,
title: "menu.live",
title: "menu.live.title",
url: "/",
},
{

View File

@ -1,7 +1,8 @@
import { baseUrl } from "@/api/baseUrl";
import TimeAgo from "@/components/dynamic/TimeAgo";
import AddFaceIcon from "@/components/icons/AddFaceIcon";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog";
import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog";
import { Button } from "@/components/ui/button";
import {
@ -25,6 +26,7 @@ import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { LuImagePlus, LuRefreshCw, LuScanFace, LuTrash2 } from "react-icons/lu";
import { toast } from "sonner";
@ -115,42 +117,16 @@ export default function FaceLibrary() {
[pageToggle, refreshFaces, t],
);
const onAddName = useCallback(
(name: string) => {
axios
.post(`faces/${name}/create`, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((resp) => {
if (resp.status == 200) {
setAddFace(false);
refreshFaces();
toast.success(t("toast.success.addFaceLibrary"), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.addFaceLibraryFailed", { errorMessage }), {
position: "top-center",
});
});
},
[refreshFaces, t],
);
// face multiselect
const [selectedFaces, setSelectedFaces] = useState<string[]>([]);
const onClickFace = useCallback(
(imageId: string) => {
(imageId: string, ctrl: boolean) => {
if (selectedFaces.length == 0 && !ctrl) {
return;
}
const index = selectedFaces.indexOf(imageId);
if (index != -1) {
@ -172,33 +148,42 @@ export default function FaceLibrary() {
[selectedFaces, setSelectedFaces],
);
const onDelete = useCallback(() => {
axios
.post(`/faces/train/delete`, { ids: selectedFaces })
.then((resp) => {
setSelectedFaces([]);
const onDelete = useCallback(
(name: string, ids: string[]) => {
axios
.post(`/faces/${name}/delete`, { ids })
.then((resp) => {
setSelectedFaces([]);
if (resp.status == 200) {
toast.success(t("toast.success.deletedFace"), {
if (resp.status == 200) {
toast.success(t("toast.success.deletedFace"), {
position: "top-center",
});
if (faceImages.length == 1) {
// face has been deleted
setPageToggle("");
}
refreshFaces();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
position: "top-center",
});
refreshFaces();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
position: "top-center",
});
});
}, [selectedFaces, refreshFaces, t]);
},
[faceImages, refreshFaces, setPageToggle, t],
);
// keyboard
useKeyboardListener(["a"], (key, modifiers) => {
useKeyboardListener(["a", "Escape"], (key, modifiers) => {
if (modifiers.repeat || !modifiers.down) {
return;
}
@ -209,6 +194,9 @@ export default function FaceLibrary() {
setSelectedFaces([...trainImages]);
}
break;
case "Escape":
setSelectedFaces([]);
break;
}
});
@ -228,12 +216,10 @@ export default function FaceLibrary() {
onSave={onUploadImage}
/>
<TextEntryDialog
title={t("createFaceLibrary.title")}
description={t("createFaceLibrary.desc")}
<CreateFaceWizardDialog
open={addFace}
setOpen={setAddFace}
onSave={onAddName}
onFinish={refreshFaces}
/>
<div className="relative mb-2 flex h-11 w-full items-center justify-between">
@ -283,21 +269,24 @@ export default function FaceLibrary() {
</ScrollArea>
{selectedFaces?.length > 0 ? (
<div className="flex items-center justify-center gap-2">
<Button className="flex gap-2" onClick={() => onDelete()}>
<Button
className="flex gap-2"
onClick={() => onDelete("train", selectedFaces)}
>
<LuTrash2 className="size-7 rounded-md p-1 text-secondary-foreground" />
{t("button.deleteFaceAttempts")}
{isDesktop && t("button.deleteFaceAttempts")}
</Button>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<Button className="flex gap-2" onClick={() => setAddFace(true)}>
<LuScanFace className="size-7 rounded-md p-1 text-secondary-foreground" />
{t("button.addFace")}
{isDesktop && t("button.addFace")}
</Button>
{pageToggle != "train" && (
<Button className="flex gap-2" onClick={() => setUpload(true)}>
<LuImagePlus className="size-7 rounded-md p-1 text-secondary-foreground" />
{t("button.uploadImage")}
{isDesktop && t("button.uploadImage")}
</Button>
)}
</div>
@ -317,7 +306,7 @@ export default function FaceLibrary() {
<FaceGrid
faceImages={faceImages}
pageToggle={pageToggle}
onRefresh={refreshFaces}
onDelete={onDelete}
/>
))}
</div>
@ -329,7 +318,7 @@ type TrainingGridProps = {
attemptImages: string[];
faceNames: string[];
selectedFaces: string[];
onClickFace: (image: string) => void;
onClickFace: (image: string, ctrl: boolean) => void;
onRefresh: () => void;
};
function TrainingGrid({
@ -349,7 +338,7 @@ function TrainingGrid({
faceNames={faceNames}
threshold={config.face_recognition.recognition_threshold}
selected={selectedFaces.includes(image)}
onClick={() => onClickFace(image)}
onClick={(meta) => onClickFace(image, meta)}
onRefresh={onRefresh}
/>
))}
@ -362,7 +351,7 @@ type FaceAttemptProps = {
faceNames: string[];
threshold: number;
selected: boolean;
onClick: () => void;
onClick: (meta: boolean) => void;
onRefresh: () => void;
};
function FaceAttempt({
@ -378,6 +367,7 @@ function FaceAttempt({
const parts = image.split("-");
return {
timestamp: Number.parseFloat(parts[0]),
eventId: `${parts[0]}-${parts[1]}`,
name: parts[2],
score: parts[3],
@ -439,10 +429,13 @@ function FaceAttempt({
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)}
onClick={onClick}
onClick={(e) => onClick(e.metaKey || e.ctrlKey)}
>
<div className="w-full overflow-hidden rounded-t-lg border border-t-0 *:text-card-foreground">
<img className="size-40" src={`${baseUrl}clips/faces/train/${image}`} />
<div className="relative w-full overflow-hidden rounded-t-lg border border-t-0 *:text-card-foreground">
<img className="size-44" src={`${baseUrl}clips/faces/train/${image}`} />
<div className="absolute bottom-1 right-1 z-10 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
<TimeAgo time={data.timestamp * 1000} dense />
</div>
</div>
<div className="rounded-b-lg bg-card p-2">
<div className="flex w-full flex-row items-center justify-between gap-2">
@ -479,7 +472,7 @@ function FaceAttempt({
))}
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>{t("trainFaceAsPerson")}</TooltipContent>
<TooltipContent>{t("trainFace")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
@ -500,9 +493,9 @@ function FaceAttempt({
type FaceGridProps = {
faceImages: string[];
pageToggle: string;
onRefresh: () => void;
onDelete: (name: string, ids: string[]) => void;
};
function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) {
function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) {
return (
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll">
{faceImages.map((image: string) => (
@ -510,7 +503,7 @@ function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) {
key={image}
name={pageToggle}
image={image}
onRefresh={onRefresh}
onDelete={onDelete}
/>
))}
</div>
@ -520,31 +513,10 @@ function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) {
type FaceImageProps = {
name: string;
image: string;
onRefresh: () => void;
onDelete: (name: string, ids: string[]) => void;
};
function FaceImage({ name, image, onRefresh }: FaceImageProps) {
function FaceImage({ name, image, onDelete }: FaceImageProps) {
const { t } = useTranslation(["views/faceLibrary"]);
const onDelete = useCallback(() => {
axios
.post(`/faces/${name}/delete`, { ids: [image] })
.then((resp) => {
if (resp.status == 200) {
toast.success(t("toast.success.deletedFace"), {
position: "top-center",
});
onRefresh();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
position: "top-center",
});
});
}, [name, image, onRefresh, t]);
return (
<div className="relative flex flex-col rounded-lg">
@ -561,7 +533,7 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) {
<TooltipTrigger>
<LuTrash2
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={onDelete}
onClick={() => onDelete(name, [image])}
/>
</TooltipTrigger>
<TooltipContent>{t("button.deleteFaceAttempts")}</TooltipContent>

View File

@ -1,20 +1,24 @@
import { UserAuthForm } from "@/components/auth/AuthForm";
import Logo from "@/components/Logo";
import { ThemeProvider } from "@/context/theme-provider";
import "@/utils/i18n";
import { LanguageProvider } from "@/context/language-provider";
function LoginPage() {
return (
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
<div className="size-full overflow-hidden">
<div className="p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col items-center space-y-2">
<Logo className="mb-6 h-8 w-8" />
<LanguageProvider>
<div className="size-full overflow-hidden">
<div className="p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col items-center space-y-2">
<Logo className="mb-6 h-8 w-8" />
</div>
<UserAuthForm />
</div>
<UserAuthForm />
</div>
</div>
</div>
</LanguageProvider>
</ThemeProvider>
);
}

View File

@ -37,6 +37,7 @@ import AuthenticationView from "@/views/settings/AuthenticationView";
import NotificationView from "@/views/settings/NotificationsSettingsView";
import ClassificationSettingsView from "@/views/settings/ClassificationSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView";
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws";
@ -54,6 +55,7 @@ const allSettingsViews = [
"debug",
"users",
"notifications",
"frigateplus",
] as const;
type SettingsType = (typeof allSettingsViews)[number];
@ -216,8 +218,8 @@ export default function Settings() {
value={item}
data-nav-item={item}
aria-label={t("selectItem", {
item: t("menu." + item),
ns: "common",
item: t("menu." + item),
})}
>
<div className="capitalize">{t("menu." + item)}</div>
@ -279,6 +281,7 @@ export default function Settings() {
{page == "notifications" && (
<NotificationView setUnsavedChanges={setUnsavedChanges} />
)}
{page == "frigateplus" && <FrigatePlusSettingsView />}
</div>
{confirmationDialogOpen && (
<AlertDialog

View File

@ -391,6 +391,12 @@ export interface FrigateConfig {
colormap: { [key: string]: [number, number, number] };
attributes_map: { [key: string]: [string] };
all_attributes: [string];
plus?: {
name: string;
trainDate: string;
baseModel: string;
supportedDetectors: string[];
};
};
motion: Record<string, unknown> | null;

View File

@ -50,6 +50,7 @@ export type SearchResult = {
score: number;
sub_label_score?: number;
region: number[];
attributes?: [{ box: number[]; label: string; score: number }];
box: number[];
area: number;
ratio: number;

View File

@ -10,7 +10,7 @@ export function shareOrCopy(url: string, title?: string) {
});
} else {
copy(url);
toast.success(t("toast.copyUrlToClipboard"), {
toast.success(t("toast.copyUrlToClipboard", { ns: "common" }), {
position: "top-center",
});
}

View File

@ -14,24 +14,24 @@ export function getLifecycleItemDescription(
switch (lifecycleItem.class_type) {
case "visible":
return t("objectLifecycle.lifecycleItemDesc.visible", {
label,
ns: "views/explore",
label,
});
case "entered_zone":
return t("objectLifecycle.lifecycleItemDesc.entered_zone", {
label,
ns: "views/explore",
label,
zones: lifecycleItem.data.zones.join(" and ").replaceAll("_", " "),
});
case "active":
return t("objectLifecycle.lifecycleItemDesc.active", {
label,
ns: "views/explore",
label,
});
case "stationary":
return t("objectLifecycle.lifecycleItemDesc.stationary", {
label,
ns: "views/explore",
label,
});
case "attribute": {
let title = "";
@ -42,34 +42,34 @@ export function getLifecycleItemDescription(
title = t(
"objectLifecycle.lifecycleItemDesc.attribute.faceOrLicense_plate",
{
ns: "views/explore",
label,
attribute: lifecycleItem.data.attribute.replaceAll("_", " "),
ns: "views/explore",
},
);
} else {
title = t("objectLifecycle.lifecycleItemDesc.attribute.other", {
ns: "views/explore",
label: lifecycleItem.data.label,
attribute: lifecycleItem.data.attribute.replaceAll("_", " "),
ns: "views/explore",
});
}
return title;
}
case "gone":
return t("objectLifecycle.lifecycleItemDesc.gone", {
label,
ns: "views/explore",
label,
});
case "heard":
return t("objectLifecycle.lifecycleItemDesc.heard", {
label,
ns: "views/explore",
label,
});
case "external":
return t("objectLifecycle.lifecycleItemDesc.external", {
label,
ns: "views/explore",
label,
});
}
}

View File

@ -134,7 +134,7 @@ export default function LiveCameraView({
fullscreen,
toggleFullscreen,
}: LiveCameraViewProps) {
const { t } = useTranslation(["views/live"]);
const { t } = useTranslation(["views/live", "components/dialog"]);
const navigate = useNavigate();
const { isPortrait } = useMobileOrientation();
const mainRef = useRef<HTMLDivElement | null>(null);
@ -530,7 +530,13 @@ export default function LiveCameraView({
variant={fullscreen ? "overlay" : "primary"}
Icon={mic ? FaMicrophone : FaMicrophoneSlash}
isActive={mic}
title={`${mic ? "Disable" : "Enable"} Two Way Talk`}
title={
(mic
? t("button.disable", { ns: "common" })
: t("button.enable", { ns: "common" })) +
" " +
t("button.twoWayTalk", { ns: "common" })
}
onClick={() => {
setMic(!mic);
if (!mic && !audio) {
@ -546,7 +552,13 @@ export default function LiveCameraView({
variant={fullscreen ? "overlay" : "primary"}
Icon={audio ? GiSpeaker : GiSpeakerOff}
isActive={audio ?? false}
title={`${audio ? "Disable" : "Enable"} Camera Audio`}
title={
(audio
? t("button.disable", { ns: "common" })
: t("button.enable", { ns: "common" })) +
" " +
t("button.cameraAudio", { ns: "common" })
}
onClick={() => setAudio(!audio)}
disabled={!cameraEnabled}
/>
@ -931,7 +943,7 @@ function PtzControlPanel({
}
function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
const { t } = useTranslation(["views/live"]);
const { t } = useTranslation(["views/live", "views/events"]);
const rankMap = { all: 0, motion: 1, active_objects: 2 };
const getValidMode = (retain?: { mode?: string }): keyof typeof rankMap => {
const mode = retain?.mode;
@ -946,10 +958,7 @@ function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
? recordRetainMode
: alertsRetainMode;
const source =
effectiveRetainMode === recordRetainMode
? t("camera", { ns: "views/events" })
: t("alerts", { ns: "views/events" });
const source = effectiveRetainMode === recordRetainMode ? "camera" : "alerts";
return effectiveRetainMode !== "all" ? (
<div>
@ -1007,7 +1016,7 @@ function FrigateCameraFeatures({
supports2WayTalk,
cameraEnabled,
}: FrigateCameraFeaturesProps) {
const { t } = useTranslation(["views/live"]);
const { t } = useTranslation(["views/live", "components/dialog"]);
const { payload: detectState, send: sendDetect } = useDetectState(
camera.name,
@ -1243,7 +1252,7 @@ function FrigateCameraFeatures({
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>
{t("streaming.restreaming.NotEnabled", {
{t("streaming.restreaming.disabled", {
ns: "components/dialog",
})}
</div>
@ -1257,7 +1266,7 @@ function FrigateCameraFeatures({
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("streaming.restreaming.desc", {
{t("streaming.restreaming.desc.title", {
ns: "components/dialog",
})}
<div className="mt-2 flex items-center text-primary">
@ -1267,9 +1276,12 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
{t("streaming.restreaming.readTheDocumentation", {
ns: "components/dialog",
})}
{t(
"streaming.restreaming.desc.readTheDocumentation",
{
ns: "components/dialog",
},
)}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@ -1334,7 +1346,7 @@ function FrigateCameraFeatures({
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("stream.audio.tips")}
{t("stream.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@ -1462,12 +1474,16 @@ function FrigateCameraFeatures({
/>
</div>
<p className="text-sm text-muted-foreground">
{t("streaming.showStats.desc", { ns: "components/dialog" })}
{t("streaming.showStats.desc", {
ns: "components/dialog",
})}
</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-sm font-medium leading-none">
{t("streaming.debugView", { ns: "components/dialog" })}
{t("streaming.debugView", {
ns: "components/dialog",
})}
<LuExternalLink
onClick={() =>
navigate(`/settings?page=debug&camera=${camera.name}`)
@ -1558,7 +1574,7 @@ function FrigateCameraFeatures({
<div className="mt-3 flex flex-col gap-5">
{!isRestreamed && (
<div className="flex flex-col gap-2 p-2">
<Label>{t("streaming", { ns: "components/dialog" })}</Label>
<Label>{t("streaming.title", { ns: "components/dialog" })}</Label>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>
@ -1649,7 +1665,7 @@ function FrigateCameraFeatures({
</div>
</PopoverTrigger>
<PopoverContent className="w-52 text-xs">
{t("stream.audio.tips")}
{t("stream.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@ -1717,7 +1733,7 @@ function FrigateCameraFeatures({
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label="Reset the stream"
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
onClick={() => setLowBandwidth(false)}

View File

@ -417,7 +417,7 @@ export function RecordingView({
<FaVideo className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("menu.live", { ns: "common" })}
{t("menu.live.title", { ns: "common" })}
</div>
)}
</Button>

View File

@ -199,7 +199,7 @@ export default function AuthenticationView() {
<div className="mb-5 flex flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start">
<Heading as="h3" className="my-2">
{t("users.management")}
{t("users.management.title")}
</Heading>
<p className="text-sm text-muted-foreground">
{t("users.management.desc")}

View File

@ -166,7 +166,7 @@ export default function CameraSettingsView({
updateConfig();
} else {
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
@ -182,7 +182,7 @@ export default function CameraSettingsView({
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage,
ns: "common",
}),
@ -574,7 +574,7 @@ export default function CameraSettingsView({
watchedDetectionsZones.length > 0 ? (
!selectDetections ? (
<Trans
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips"
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.text"
values={{
detectionsLabels,
zone: watchedDetectionsZones

View File

@ -162,9 +162,12 @@ export default function ClassificationSettingsView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
@ -319,7 +322,11 @@ export default function ClassificationSettingsView({
className="cursor-pointer"
value={size}
>
{t("classification.semanticSearch.modelSize." + size)}
{t(
"classification.semanticSearch.modelSize." +
size +
".title",
)}
</SelectItem>
))}
</SelectGroup>

View File

@ -0,0 +1,229 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import { useEffect } from "react";
import { Toaster } from "sonner";
import { Separator } from "../../components/ui/separator";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { CheckCircle2, XCircle } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { IoIosWarning } from "react-icons/io";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
export default function FrigatePlusSettingsView() {
const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation("views/settings");
useEffect(() => {
document.title = t("documentTitle.frigatePlus");
}, [t]);
const needCleanSnapshots = () => {
if (!config) {
return false;
}
return Object.values(config.cameras).some(
(camera) => camera.snapshots.enabled && !camera.snapshots.clean_copy,
);
};
return (
<>
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 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:mb-0 md:mr-2 md:mt-0">
<Heading as="h3" className="my-2">
{t("frigatePlus.title")}
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("frigatePlus.apiKey.title")}
</Heading>
<div className="mt-2 space-y-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
{config?.plus?.enabled ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<Label>
{config?.plus?.enabled
? t("frigatePlus.apiKey.validated")
: t("frigatePlus.apiKey.notValidated")}
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>{t("frigatePlus.apiKey.desc")}</p>
{!config?.model.plus && (
<>
<div className="mt-2 flex items-center text-primary-variant">
<Link
to="https://frigate.video/plus"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("frigatePlus.apiKey.plusLink")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</>
)}
</div>
</div>
{config?.model.plus && (
<>
<Separator className="my-2 flex bg-secondary" />
<div className="mt-2 max-w-2xl">
<Heading as="h4" className="my-2">
{t("frigatePlus.modelInfo.title")}
</Heading>
<div className="mt-2 space-y-3">
{!config?.model?.plus && (
<p className="text-muted-foreground">
{t("frigatePlus.modelInfo.loading")}
</p>
)}
{config?.model?.plus === null && (
<p className="text-danger">
{t("frigatePlus.modelInfo.error")}
</p>
)}
{config?.model?.plus && (
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.modelType")}
</Label>
<p>{config.model.plus.name}</p>
</div>
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.trainDate")}
</Label>
<p>
{new Date(
config.model.plus.trainDate,
).toLocaleString()}
</p>
</div>
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.baseModel")}
</Label>
<p>{config.model.plus.baseModel}</p>
</div>
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.supportedDetectors")}
</Label>
<p>
{config.model.plus.supportedDetectors.join(", ")}
</p>
</div>
</div>
)}
</div>
</div>
</>
)}
<Separator className="my-2 flex bg-secondary" />
<div className="mt-2 max-w-5xl">
<Heading as="h4" className="my-2">
{t("frigatePlus.snapshotConfig.title")}
</Heading>
<div className="mt-2 space-y-3">
<div className="my-2 text-sm text-muted-foreground">
<p>
<Trans ns="views/settings">
frigatePlus.snapshotConfig.desc
</Trans>
</p>
<div className="mt-2 flex items-center text-primary-variant">
<Link
to="https://docs.frigate.video/configuration/plus/faq"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("frigatePlus.snapshotConfig.documentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
{config && (
<div className="overflow-x-auto">
<table className="max-w-2xl text-sm">
<thead>
<tr className="border-b border-secondary">
<th className="px-4 py-2 text-left">
{t("frigatePlus.snapshotConfig.table.camera")}
</th>
<th className="px-4 py-2 text-center">
{t("frigatePlus.snapshotConfig.table.snapshots")}
</th>
<th className="px-4 py-2 text-center">
<Trans ns="views/settings">
frigatePlus.snapshotConfig.table.cleanCopySnapshots
</Trans>
</th>
</tr>
</thead>
<tbody>
{Object.entries(config.cameras).map(
([name, camera]) => (
<tr
key={name}
className="border-b border-secondary"
>
<td className="px-4 py-2">{name}</td>
<td className="px-4 py-2 text-center">
{camera.snapshots.enabled ? (
<CheckCircle2 className="mx-auto size-5 text-green-500" />
) : (
<XCircle className="mx-auto size-5 text-danger" />
)}
</td>
<td className="px-4 py-2 text-center">
{camera.snapshots?.enabled &&
camera.snapshots?.clean_copy ? (
<CheckCircle2 className="mx-auto size-5 text-green-500" />
) : (
<XCircle className="mx-auto size-5 text-danger" />
)}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
{needCleanSnapshots() && (
<div className="mt-2 max-w-xl rounded-lg border border-secondary-foreground bg-secondary p-4 text-sm text-danger">
<div className="flex items-center gap-2">
<IoIosWarning className="mr-2 size-5 text-danger" />
<div className="max-w-[85%] text-sm">
<Trans ns="views/settings">
frigatePlus.snapshotConfig.cleanCopyWarning
</Trans>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -502,7 +502,7 @@ export default function MasksAndZonesView({
</HoverCardTrigger>
<HoverCardContent>
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>{t("masksAndZones.zones.desc")}</p>
<p>{t("masksAndZones.zones.desc.title")}</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/zones"
@ -568,7 +568,7 @@ export default function MasksAndZonesView({
</HoverCardTrigger>
<HoverCardContent>
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>{t("masksAndZones.motionMasks.desc")}</p>
<p>{t("masksAndZones.motionMasks.desc.title")}</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/masks#motion-masks"
@ -638,7 +638,7 @@ export default function MasksAndZonesView({
</HoverCardTrigger>
<HoverCardContent>
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>{t("masksAndZones.objectMasks.desc")}</p>
<p>{t("masksAndZones.objectMasks.desc.title")}</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/masks#object-filter-masks"
@ -646,7 +646,9 @@ export default function MasksAndZonesView({
rel="noopener noreferrer"
className="inline"
>
{t("masksAndZones.objectMasks.documentation")}{" "}
{t(
"masksAndZones.objectMasks.desc.documentation",
)}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>

View File

@ -126,7 +126,7 @@ export default function MotionTunerView({
updateConfig();
} else {
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
@ -138,7 +138,7 @@ export default function MotionTunerView({
})
.catch((error) => {
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage: error.response.data.message,
ns: "common",
}),
@ -194,7 +194,7 @@ export default function MotionTunerView({
{t("motionDetectionTuner.title")}
</Heading>
<div className="my-3 space-y-3 text-sm text-muted-foreground">
<p>{t("motionDetectionTuner.desc")}</p>
<p>{t("motionDetectionTuner.desc.title")}</p>
<div className="flex items-center text-primary">
<Link
@ -213,7 +213,7 @@ export default function MotionTunerView({
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<Label htmlFor="motion-threshold" className="text-md">
{t("motionDetectionTuner.Threshold")}
{t("motionDetectionTuner.Threshold.title")}
</Label>
<div className="my-2 text-sm text-muted-foreground">
<Trans ns="views/settings">
@ -242,7 +242,7 @@ export default function MotionTunerView({
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<Label htmlFor="motion-threshold" className="text-md">
{t("motionDetectionTuner.contourArea")}
{t("motionDetectionTuner.contourArea.title")}
</Label>
<div className="my-2 text-sm text-muted-foreground">
<p>
@ -274,7 +274,7 @@ export default function MotionTunerView({
<div className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="improve-contrast">
{t("motionDetectionTuner.improveContrast")}
{t("motionDetectionTuner.improveContrast.title")}
</Label>
<div className="text-sm text-muted-foreground">
<Trans ns="views/settings">
@ -323,7 +323,7 @@ export default function MotionTunerView({
</div>
{cameraConfig ? (
<div className="flex md:h-dvh md:max-h-full md:w-7/12 md:grow">
<div className="flex max-h-[70%] md:h-dvh md:max-h-full md:w-7/12 md:grow">
<div className="size-full min-h-10">
<AutoUpdatingCameraImage
camera={cameraConfig.name}

View File

@ -264,7 +264,7 @@ export default function NotificationView({
updateConfig();
} else {
toast.error(
t("toast.save.error", {
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
@ -279,9 +279,12 @@ export default function NotificationView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
@ -389,7 +392,7 @@ export default function NotificationView({
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("notification.email")}</FormLabel>
<FormLabel>{t("notification.email.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] md:w-72"
@ -414,7 +417,7 @@ export default function NotificationView({
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
{t("notification.cameras")}
{t("notification.cameras.title")}
</FormLabel>
</div>
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
@ -423,7 +426,7 @@ export default function NotificationView({
name="allEnabled"
render={({ field }) => (
<FilterSwitch
label={t("cameras.all", {
label={t("cameras.all.title", {
ns: "components/filter",
})}
isChecked={field.value}
@ -514,7 +517,7 @@ export default function NotificationView({
{t("notification.deviceSpecific")}
</Heading>
<Button
aria-label="Register or unregister notifications for this device"
aria-label={t("notification.registerDevice")}
disabled={
!config?.notifications.enabled || publicKey == undefined
}
@ -653,7 +656,7 @@ export function CameraNotificationSwitch({
strftime_fmt:
config?.ui.time_format == "24hour"
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
: t("time.formattedTimestampExcludeSeconds", { ns: "common" }),
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
});
return t("time.untilForTime", { ns: "common", time });
};
@ -709,10 +712,10 @@ export function CameraNotificationSwitch({
{t("notification.suspendTime.1hour")}
</SelectItem>
<SelectItem value="840">
{t("notification.suspendTime.12hour")}
{t("notification.suspendTime.12hours")}
</SelectItem>
<SelectItem value="1440">
{t("notification.suspendTime.24hour")}
{t("notification.suspendTime.24hours")}
</SelectItem>
<SelectItem value="off">
{t("notification.suspendTime.untilRestart")}

Some files were not shown because too many files have changed in this diff Show More