diff --git a/web/package-lock.json b/web/package-lock.json
index 97a0d991b..986677695 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -41,6 +41,8 @@
"embla-carousel-react": "^8.2.0",
"framer-motion": "^11.5.4",
"hls.js": "^1.5.20",
+ "i18next": "^24.2.0",
+ "i18next-http-backend": "^3.0.1",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
"konva": "^9.3.18",
@@ -56,6 +58,7 @@
"react-dom": "^18.3.1",
"react-grid-layout": "^1.5.0",
"react-hook-form": "^7.52.1",
+ "react-i18next": "^15.2.0",
"react-icons": "^5.5.0",
"react-konva": "^18.2.10",
"react-router-dom": "^6.26.0",
@@ -192,9 +195,10 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.24.4",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz",
- "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==",
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
+ "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
+ "license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@@ -4306,6 +4310,15 @@
"toggle-selection": "^1.0.6"
}
},
+ "node_modules/cross-fetch": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
+ "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
+ "license": "MIT",
+ "dependencies": {
+ "node-fetch": "^2.6.12"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -5427,6 +5440,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -5455,6 +5477,46 @@
"node": ">= 14"
}
},
+ "node_modules/i18next": {
+ "version": "24.2.0",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz",
+ "integrity": "sha512-ArJJTS1lV6lgKH7yEf4EpgNZ7+THl7bsGxxougPYiXRTJ/Fe1j08/TBpV9QsXCIYVfdE/HWG/xLezJ5DOlfBOA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://locize.com"
+ },
+ {
+ "type": "individual",
+ "url": "https://locize.com/i18next.html"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ },
+ "peerDependencies": {
+ "typescript": "^5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/i18next-http-backend": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.1.tgz",
+ "integrity": "sha512-XT2lYSkbAtDE55c6m7CtKxxrsfuRQO3rUfHzj8ZyRtY9CkIX3aRGwXGTkUhpGWce+J8n7sfu3J0f2wTzo7Lw0A==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-fetch": "4.0.0"
+ }
+ },
"node_modules/idb-keyval": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
@@ -6359,6 +6421,48 @@
"react-dom": "^16.8 || ^17 || ^18"
}
},
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/node-fetch/node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
+ "node_modules/node-fetch/node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/node-fetch/node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
@@ -7151,6 +7255,28 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
+ "node_modules/react-i18next": {
+ "version": "15.2.0",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.2.0.tgz",
+ "integrity": "sha512-iJNc8111EaDtVTVMKigvBtPHyrJV+KblWG73cUxqp+WmJCcwkzhWNFXmkAD5pwP2Z4woeDj/oXDdbjDsb3Gutg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.25.0",
+ "html-parse-stringify": "^3.0.1"
+ },
+ "peerDependencies": {
+ "i18next": ">= 23.2.3",
+ "react": ">= 16.8.0"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
@@ -8455,7 +8581,7 @@
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -8881,6 +9007,15 @@
}
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
diff --git a/web/package.json b/web/package.json
index 59a0a5d03..37233a976 100644
--- a/web/package.json
+++ b/web/package.json
@@ -47,6 +47,8 @@
"embla-carousel-react": "^8.2.0",
"framer-motion": "^11.5.4",
"hls.js": "^1.5.20",
+ "i18next": "^24.2.0",
+ "i18next-http-backend": "^3.0.1",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
"konva": "^9.3.18",
@@ -62,6 +64,7 @@
"react-dom": "^18.3.1",
"react-grid-layout": "^1.5.0",
"react-hook-form": "^7.52.1",
+ "react-i18next": "^15.2.0",
"react-icons": "^5.5.0",
"react-konva": "^18.2.10",
"react-router-dom": "^6.26.0",
diff --git a/web/public/locales/en/audio.json b/web/public/locales/en/audio.json
new file mode 100644
index 000000000..38dea5f2c
--- /dev/null
+++ b/web/public/locales/en/audio.json
@@ -0,0 +1,8 @@
+{
+ "crying": "Crying",
+ "laughter": "Laughter",
+ "scream": "Scream",
+ "speech": "Speech",
+ "yell": "Yell",
+ "fire_alarm": "Fire alarm"
+}
diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json
new file mode 100644
index 000000000..ce88e4f72
--- /dev/null
+++ b/web/public/locales/en/common.json
@@ -0,0 +1,173 @@
+{
+ "time": {
+ "untilForTime": "Until {{time}}",
+ "untilForRestart": "Until Frigate restarts.",
+ "untilRestart": "Until restart",
+ "ago": "{{timeAgo}} ago",
+ "justNow": "Just now",
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "last7": "Last 7 days",
+ "last14": "Last 14 days",
+ "last30": "Last 30 days",
+ "thisWeek": "This Week",
+ "lastWeek": "Last Week",
+ "thisMonth": "This Month",
+ "lastMonth": "Last Month",
+ "5minutes": "5 minutes",
+ "10minutes": "10 minutes",
+ "30minutes": "30 minutes",
+ "1hour": "1 hour",
+ "12hours": "12 hours",
+ "24hours": "24 hours",
+ "pm": "pm",
+ "am": "am",
+ "yr": "{{time}}yr",
+ "year": "{{time}} years",
+ "mo": "{{time}}mo",
+ "month": "{{time}} months",
+ "d": "{{time}}d",
+ "day": "{{time}} days",
+ "h": "{{time}}h",
+ "hour": "{{time}} hours",
+ "m": "{{time}}m",
+ "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",
+ "formattedTimestampOnlyMonthAndDay": "%b %-d"
+ },
+ "unit": {
+ "speed": {
+ "mph": "mph",
+ "kph": "kph"
+ }
+ },
+ "label": {
+ "back": "Go back"
+ },
+ "button": {
+ "apply": "Apply",
+ "reset": "Reset",
+ "enabled": "Enabled",
+ "enable": "Enable",
+ "disabled": "Disabled",
+ "disable": "Disable",
+ "save": "Save",
+ "saving": "Saving...",
+ "cancel": "Cancel",
+ "close": "Close",
+ "copy": "Copy",
+ "back": "Back",
+ "history": "History",
+ "fullscreen": "Fullscreen",
+ "exitFullscreen": "Exit Fullscreen",
+ "pictureInPicture": "Picture in Picture",
+ "on": "ON",
+ "off": "OFF",
+ "edit": "Edit",
+ "copyCoordinates": "Copy coordinates",
+ "delete": "Delete",
+ "yes": "Yes",
+ "no": "No",
+ "download": "Download",
+ "info": "Info",
+ "suspended": "Suspended",
+ "unsuspended": "Unsuspend",
+ "play": "Play",
+ "unselect": "Unselect",
+ "export": "Export",
+ "deleteNow": "Delete Now"
+ },
+ "menu": {
+ "system": "System",
+ "systemMetrics": "System metrics",
+ "configuration": "Configuration",
+ "systemLogs": "System logs",
+ "settings": "Settings",
+ "configurationEditor": "Configuration Editor",
+ "languages": "Languages",
+ "language": {
+ "en": "English",
+ "zhCN": "简体中文(Simplified Chinese)",
+ "withSystem.label": "Use the system settings for languag"
+ },
+ "appearance": "Appearance",
+ "darkMode": {
+ "label": "Dark Mode",
+ "light": "Light",
+ "dark": "Dark",
+ "withSystem.label": "Use the system settings for light or dark mode"
+ },
+ "withSystem": "System",
+ "theme": {
+ "label": "Theme",
+ "blue": "Blue",
+ "green": "Green",
+ "nord": "Nord",
+ "red": "Red",
+ "contrast": "High Contrast",
+ "default": "Default"
+ },
+ "help": "Help",
+ "documentation.label": "Frigate documentation",
+ "documentation": "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",
+ "review": "Review",
+ "explore": "Explore",
+ "export": "Export",
+ "uiPlayground": "UI Playground",
+ "faceLibrary": "Face Library",
+ "user": {
+ "account": "Account",
+ "current": "Current User: {{user}}",
+ "anonymous": "anonymous",
+ "logout": "Logout",
+ "setPassword": "Set Password"
+ }
+ },
+ "toast": {
+ "copyUrlToClipboard": "Copied URL to clipboard.",
+ "save": {
+ "error": "Failed to save config changes: {{errorMessage}}",
+ "error.noMessage": "Failed to save config changes"
+ }
+ },
+ "role": {
+ "title": "Role",
+ "admin": "Admin",
+ "viewer": "Viewer",
+ "desc": "Admins have full access to all features in the Frigate UI. Viewers are limited to viewing cameras, review items, and historical footage in the UI."
+ },
+ "pagination": {
+ "label": "pagination",
+ "previous": "Previous",
+ "previous.label": "Go to previous page",
+ "next": "Next",
+ "next.label": "Go to next page",
+ "more": "More pages"
+ },
+ "accessDenied": {
+ "documentTitle": "Access Denied - Frigate",
+ "title": "Access Denied",
+ "desc": "You don't have permission to view this page."
+ },
+ "notFound": {
+ "documentTitle": "Not Found - Frigate",
+ "title": "404",
+ "desc": "Page not found"
+ },
+ "selectItem": "Select {{item}}"
+}
diff --git a/web/public/locales/en/components/auth.json b/web/public/locales/en/components/auth.json
new file mode 100644
index 000000000..bec032838
--- /dev/null
+++ b/web/public/locales/en/components/auth.json
@@ -0,0 +1,15 @@
+{
+ "form": {
+ "user": "Username",
+ "password": "Password",
+ "login": "Login",
+ "errors": {
+ "usernameRequired": "Username is required",
+ "passwordRequired": "Password is required",
+ "rateLimit": "Exceeded rate limit. Try again later.",
+ "loginFailed": "Login failed",
+ "unknownError": "Unknown error. Check logs.",
+ "webUnkownError": "Unknown error. Check console logs."
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/en/components/camera.json b/web/public/locales/en/components/camera.json
new file mode 100644
index 000000000..049384a42
--- /dev/null
+++ b/web/public/locales/en/components/camera.json
@@ -0,0 +1,77 @@
+{
+ "group": {
+ "label": "Camera Groups",
+ "add": "Add camera groups",
+ "edit": "Edit camera groups",
+ "delete": {
+ "label": "Delete Camera Group",
+ "confirm": "Confirm Delete",
+ "confirm.desc": "Are you sure you want to delete the camera group {{name}} ?"
+ },
+ "name": {
+ "label": "Name",
+ "placeholder": "Enter a name...",
+ "error": {
+ "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.",
+ "invalid": "Invalid camera group name."
+ }
+ },
+ "cameras": {
+ "label": "Cameras",
+ "desc": "Select cameras for this group."
+ },
+ "icon": "Icon",
+ "success": "Camera group ({{name}}) has been saved.",
+ "camera": {
+ "setting": {
+ "label": "Camera Streaming Settings",
+ "title": "{{cameraName}} Streaming Settings",
+ "desc": "Change the live streaming options for this camera group's dashboard. These settings are device/browser-specific. ",
+ "audioIsAvailable": "Audio is available for this stream",
+ "audioIsUnavailable": "Audio is available for this stream",
+ "audio": {
+ "tips": "Audio must be output from your camera and configured in go2rtc for this stream.",
+ "tips.document": "Read the documentation "
+ },
+ "streamMethod": {
+ "label": "Streaming Method",
+ "method": {
+ "noStreaming": {
+ "label": "No Streaming",
+ "desc": "Camera images will only update once per minute and no live streaming will occur."
+ },
+ "smartStreaming": {
+ "label": "Smart Streaming (recommended)",
+ "desc": "Smart streaming will update your camera image once per minute when no detectable activity is occurring to conserve bandwidth and resources. When activity is detected, the image seamlessly switches to a live stream."
+ },
+ "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."
+ }
+ }
+ },
+ "compatibilityMode": {
+ "label": "Compatibility mode",
+ "desc": "Enable this option only if your camera's live stream is displaying color artifacts and has a diagonal line on the right side of the image."
+ }
+ }
+ }
+ },
+ "debug": {
+ "options": {
+ "label": "Settings",
+ "title": "Options",
+ "showOptions": "Show Options",
+ "hideOptions": "Hide Options"
+ },
+ "boundingBox": "Bounding Box",
+ "timestamp": "Timestamp",
+ "zones": "Zones",
+ "mask": "Mask",
+ "motion": "Motion",
+ "regions": "Regions"
+ }
+}
diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json
new file mode 100644
index 000000000..c58faca21
--- /dev/null
+++ b/web/public/locales/en/components/dialog.json
@@ -0,0 +1,96 @@
+{
+ "restart": {
+ "title": "Are you sure you want to restart Frigate?",
+ "button": "Restart",
+ "restarting": {
+ "title": "Frigate is Restarting",
+ "content": "This page will reload in {{countdown}} seconds.",
+ "button": "Force Reload Now"
+ }
+ },
+ "explore": {
+ "plus": {
+ "submitToPlus": {
+ "label": "Submit To Frigate+",
+ "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"
+ }
+ },
+ "video": {
+ "viewInHistory": "View in History"
+ }
+ },
+ "export": {
+ "time": {
+ "fromTimeline": "Select from Timeline",
+ "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"
+ },
+ "name": {
+ "placeholder": "Name the Export"
+ },
+ "select": "Select",
+ "export": "Export",
+ "selectOrExport": "Select or Export",
+ "toast": {
+ "success": "Successfully started export. View the file in the /exports folder.",
+ "error": {
+ "failed": "Failed to start export: {{error}}",
+ "endTimeMustAfterStartTime": "End time must be after start time",
+ "noVaildTimeSelected": "No valid time range selected"
+ }
+ },
+ "fromTimeline": {
+ "saveExport": "Save Export",
+ "previewExport": "Preview Export"
+ }
+ },
+ "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 "
+ },
+ "showStats": {
+ "label": "Show stream stats",
+ "desc": "Enable this option to show stream statistics as an overlay on the camera feed."
+ },
+ "debugView": "Debug View"
+ },
+ "search": {
+ "saveSearch": {
+ "label": "Save Search",
+ "desc": "Provide a name for this saved search.",
+ "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"
+ }
+ },
+ "recording": {
+ "confirmDelete": {
+ "title": "Confirm Delete",
+ "desc": "Are you sure you want to delete all recorded video associated with this review item? Hold the Shift 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? Hold the Shift key to bypass this dialog in the future."
+ },
+ "button": {
+ "export": "Export",
+ "markAsReviewed": "Mark as reviewed",
+ "deleteNow": "Delete Now"
+ }
+ }
+}
diff --git a/web/public/locales/en/components/filter.json b/web/public/locales/en/components/filter.json
new file mode 100644
index 000000000..78188452b
--- /dev/null
+++ b/web/public/locales/en/components/filter.json
@@ -0,0 +1,108 @@
+{
+ "filter": "Filter",
+ "labels": {
+ "label": "Labels",
+ "all": "All Labels",
+ "all.short": "Labels",
+ "count": "{{count}} Labels"
+ },
+ "zones": {
+ "all": "All Zones",
+ "all.short": "Zones"
+ },
+ "dates": {
+ "all": "All Dates",
+ "all.short": "Dates"
+ },
+ "more": "More Filters",
+ "reset.label": "Reset filters to default values",
+ "timeRange": "Time Range",
+ "zones.label": "Zones",
+ "subLabels": {
+ "label": "Sub Labels",
+ "all": "All Sub Labels"
+ },
+ "score": "Score",
+ "estimatedSpeed": "Estimated Speed ({{unit}})",
+ "features": {
+ "label": "Features",
+ "hasSnapshot": "Has a snapshot",
+ "hasVideoClip": "Has a video clip",
+ "submittedToFrigatePlus": {
+ "label": "Submitted to Frigate+",
+ "tips": "You must first filter on tracked objects that have a snapshot. Tracked objects without a snapshot cannot be submitted to Frigate+."
+ }
+ },
+ "sort": {
+ "label": "Sort",
+ "dateAsc": "Date (Ascending)",
+ "dateDesc": "Date (Descending)",
+ "scoreAsc": "Object Score (Ascending)",
+ "scoreDesc": "Object Score (Descending)",
+ "speedAsc": "Estimated Speed (Ascending)",
+ "speedDesc": "Estimated Speed (Descending)",
+ "relevance": "Relevance"
+ },
+ "cameras": {
+ "label": "Cameras Filter",
+ "all": "All Cameras",
+ "all.short": "Cameras"
+ },
+ "review": {
+ "showReviewed": "Show Reviewed"
+ },
+ "motion": {
+ "showMotionOnly": "Show Motion Only"
+ },
+ "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.",
+ "searchSource": {
+ "label": "Search Source",
+ "desc": "Choose whether to search the thumbnails or descriptions of your tracked objects.",
+ "options": {
+ "thumbnailImage": "Thumbnail Image",
+ "description": "Description"
+ }
+ }
+ },
+ "date": {
+ "selectDateBy": {
+ "label": "Select a date to filter by"
+ }
+ }
+ },
+ "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.",
+ "disableLogStreaming": "Disable log streaming",
+ "allLogs": "All logs"
+ },
+ "trackedObjectDelete": {
+ "title": "Confirm Delete",
+ "desc": "Deleting these {{objectLength}} tracked objects removes the snapshot, any saved embeddings, and any associated object lifecycle entries. Recorded footage of these tracked objects in History view will NOT be deleted. Are you sure you want to proceed? Hold the Shift key to bypass this dialog in the future.",
+ "toast": {
+ "success": "Tracked objects deleted successfully.",
+ "error": "Failed to delete tracked objects: {{errorMessage}}"
+ }
+ },
+ "zoneMask": {
+ "filterBy": "Filter by zone mask"
+ },
+ "recognizedLicensePlates": {
+ "title": "Recognized License Plates",
+ "loadFailed": "Failed to load recognized license plates.",
+ "loading": "Loading recognized license plates...",
+ "placeholder": "Type to search license plates...",
+ "noLicensePlatesFound": "No license plates found.",
+ "selectPlatesFromList": "Select one or more plates from the list."
+ }
+}
diff --git a/web/public/locales/en/components/icons.json b/web/public/locales/en/components/icons.json
new file mode 100644
index 000000000..22ef67e97
--- /dev/null
+++ b/web/public/locales/en/components/icons.json
@@ -0,0 +1,8 @@
+{
+ "iconPicker": {
+ "selectIcon": "Select an icon",
+ "search": {
+ "placeholder": "Search for an icon..."
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/en/components/input.json b/web/public/locales/en/components/input.json
new file mode 100644
index 000000000..6cb0ea10a
--- /dev/null
+++ b/web/public/locales/en/components/input.json
@@ -0,0 +1,10 @@
+{
+ "button": {
+ "downloadVideo": {
+ "label": "Download Video",
+ "toast": {
+ "success": "Your review item video has started downloading."
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/en/components/player.json b/web/public/locales/en/components/player.json
new file mode 100644
index 000000000..c5e9b6662
--- /dev/null
+++ b/web/public/locales/en/components/player.json
@@ -0,0 +1,39 @@
+{
+ "noRecordingsFoundForThisTime": "No recordings found for this time",
+ "noPreviewFound": "No Preview Found",
+ "noPreviewFoundFor": "No Preview Found for {{cameraName}}",
+ "submitFrigatePlus": {
+ "title": "Submit this frame to Frigate+?",
+ "submit": "Submit"
+ },
+ "livePlayerRequiredIOSVersion": "iOS 17.1 or greater is required for this live stream type.",
+ "streamOffline": {
+ "title": "Stream Offline",
+ "desc": "No frames have been received on the {{cameraName}} detect
stream, check error logs"
+ },
+ "cameraDisabled": "Camera is disabled",
+ "stats": {
+ "streamType": "Stream Type:",
+ "streamType.short": "Type",
+ "bandwidth": "Bandwidth:",
+ "bandwidth.short": "Bandwidth",
+ "latency": "Latency:",
+ "latency.short": "Latency",
+ "latency.value": "{{secounds}} seconds",
+ "latency.short.value": "{{secounds}} sec",
+ "totalFrames": "Total Frames:",
+ "droppedFrames": "Dropped Frames:",
+ "droppedFrames.short": "Dropped",
+ "droppedFrames.short.value": "{{droppedFrames}} frames",
+ "decodedFrames": "Decoded Frames:",
+ "droppedFrameRate": "Dropped Frame Rate:"
+ },
+ "toast": {
+ "success": {
+ "submittedFrigatePlus": "Successfully submitted frame to Frigate+"
+ },
+ "error": {
+ "submitFrigatePlusFailed": "Failed to submit frame to Frigate+"
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/en/objects.json b/web/public/locales/en/objects.json
new file mode 100644
index 000000000..01e6d428a
--- /dev/null
+++ b/web/public/locales/en/objects.json
@@ -0,0 +1,104 @@
+{
+ "person": "Person",
+ "bicycle": "Bicycle",
+ "car": "Car",
+ "motorcycle": "Motorcycle",
+ "airplane": "Airplane",
+ "bus": "Bus",
+ "train": "Train",
+ "boat": "Boat",
+ "traffic_light": "Traffic Light",
+ "fire_hydrant": "Fire Hydrant",
+ "street_sign": "Street Sign",
+ "stop_sign": "Stop Sign",
+ "parking_meter": "Parking Meter",
+ "bench": "Bench",
+ "bird": "Bird",
+ "cat": "Cat",
+ "dog": "Dog",
+ "horse": "Horse",
+ "sheep": "Sheep",
+ "cow": "Cow",
+ "elephant": "Elephant",
+ "bear": "Bear",
+ "zebra": "Zebra",
+ "giraffe": "Giraffe",
+ "hat": "Hat",
+ "backpack": "Backpack",
+ "umbrella": "Umbrella",
+ "shoe": "Shoe",
+ "eye_glasses": "Eye Glasses",
+ "handbag": "Handbag",
+ "tie": "Tie",
+ "suitcase": "Suitcase",
+ "frisbee": "Frisbee",
+ "skis": "Skis",
+ "snowboard": "Snowboard",
+ "sports_ball": "Sports Ball",
+ "kite": "Kite",
+ "baseball_bat": "Baseball Bat",
+ "baseball_glove": "Baseball Glove",
+ "skateboard": "Skateboard",
+ "surfboard": "Surfboard",
+ "tennis_racket": "Tennis Racket",
+ "bottle": "Bottle",
+ "plate": "Plate",
+ "wine_glass": "Wine Glass",
+ "cup": "Cup",
+ "fork": "Fork",
+ "knife": "Knife",
+ "spoon": "Spoon",
+ "bowl": "Bowl",
+ "banana": "Banana",
+ "apple": "Apple",
+ "sandwich": "Sandwich",
+ "orange": "Orange",
+ "broccoli": "Broccoli",
+ "carrot": "Carrot",
+ "hot_dog": "Hot Dog",
+ "pizza": "Pizza",
+ "donut": "Donut",
+ "cake": "Cake",
+ "chair": "Chair",
+ "couch": "Couch",
+ "potted_plant": "Potted Plant",
+ "bed": "Bed",
+ "mirror": "Mirror",
+ "dining_table": "Dining Table",
+ "window": "Window",
+ "desk": "Desk",
+ "toilet": "Toilet",
+ "door": "Door",
+ "tv": "TV",
+ "laptop": "Laptop",
+ "mouse": "Mouse",
+ "remote": "Remote",
+ "keyboard": "Keyboard",
+ "cell_phone": "Cell Phone",
+ "microwave": "Microwave",
+ "oven": "Oven",
+ "toaster": "Toaster",
+ "sink": "Sink",
+ "refrigerator": "Refrigerator",
+ "blender": "Blender",
+ "book": "Book",
+ "clock": "Clock",
+ "vase": "Vase",
+ "scissors": "Scissors",
+ "teddy_bear": "Teddy Bear",
+ "hair_dryer": "Hair Dryer",
+ "toothbrush": "Toothbrush",
+ "hair_brush": "Hair Brush",
+ "vehicle": "Vehicle",
+ "squirrel": "Squirrel",
+ "deer": "Deer",
+ "animal": "Animal",
+ "bark": "Bark",
+ "fox": "Fox",
+ "goat": "Goat",
+ "rabbit": "Rabbit",
+ "raccoon": "Raccoon",
+ "robot_lawnmower": "Robot Lawnmower",
+ "waste_bin": "Waste bin",
+ "on_demand": "On_demand"
+}
diff --git a/web/public/locales/en/views/configEditor.json b/web/public/locales/en/views/configEditor.json
new file mode 100644
index 000000000..eca79fcba
--- /dev/null
+++ b/web/public/locales/en/views/configEditor.json
@@ -0,0 +1,16 @@
+{
+ "documentTitle": "Config Editor - Frigate",
+ "configEditor": "Config Editor",
+ "copyConfig": "Copy Config",
+ "saveAndRestart": "Save & Restart",
+ "saveOnly": "Save Only",
+ "toast": {
+ "success": {
+ "copyToClipboard": "Config copied to clipboard."
+ },
+ "error": {
+ "savingError": "Error saving config"
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json
new file mode 100644
index 000000000..332817b00
--- /dev/null
+++ b/web/public/locales/en/views/events.json
@@ -0,0 +1,35 @@
+{
+ "alerts": "Alerts",
+ "detections": "Detections",
+ "motion": {
+ "label": "Motion",
+ "only": "Motion only"
+ },
+ "allCameras": "All Cameras",
+ "empty": {
+ "alert": "There are no alerts to review",
+ "detection": "There are no detections to review",
+ "motion": "No motion data found"
+ },
+ "timeline": "Timeline",
+ "timeline.aria": "Select timeline",
+ "events": {
+ "label": "Events",
+ "aria": "Select events",
+ "noFoundForTimePeriod": "No events found for this time period."
+ },
+ "documentTitle": "Review - Frigate",
+ "recordings": {
+ "documentTitle": "Recordings - Frigate"
+ },
+ "calendarFilter": {
+ "last24Hours": "Last 24 Hours"
+ },
+ "markAsReviewed": "Mark as Reviewed",
+ "markTheseItemsAsReviewed": "Mark these items as reviewed",
+ "newReviewItems": {
+ "label": "View new review items",
+ "button": "New Items To Review"
+ },
+ "camera": "Camera"
+}
diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json
new file mode 100644
index 000000000..a156afbbb
--- /dev/null
+++ b/web/public/locales/en/views/explore.json
@@ -0,0 +1,176 @@
+{
+ "documentTitle": "Explore - Frigate",
+ "generativeAI": "Generative AI",
+ "exploreIsUnavailable": {
+ "title": "Explore is Unavailable",
+ "embeddingsReindexing": {
+ "context": "Explore can be used after tracked object embeddings have finished reindexing.",
+ "startingUp": "Starting up...",
+ "estimatedTime": "Estimated time remaining:",
+ "finishingShortly": "Finishing shortly",
+ "step": {
+ "thumbnailsEmbedded": "Thumbnails embedded: ",
+ "descriptionsEmbedded": "Descriptions embedded: ",
+ "trackedObjectsProcessed": "Tracked objects processed: "
+ }
+ },
+ "downloadingModels": {
+ "context": "Frigate is downloading the necessary embeddings models to support the Semantic Search feature. This may take several minutes depending on the speed of your network connection.",
+ "setup": {
+ "visionModel": "Vision model",
+ "visionModelFeatureExtractor": "Vision model feature extractor",
+ "textModel": "Text model",
+ "textTokenizer": "Text tokenizer"
+ },
+ "tips": {
+ "context": "You may want to reindex the embeddings of your tracked objects once the models are downloaded.",
+ "documentation": "Read the documentation"
+ },
+ "error": "An error has occurred. Check Frigate logs."
+ }
+ },
+ "trackedObjectDetails": "Tracked Object Details",
+ "type": {
+ "details": "details",
+ "snapshot": "snapshot",
+ "video": "video",
+ "object_lifecycle": "object lifecycle"
+ },
+ "objectLifecycle": {
+ "title": "Object Lifecycle",
+ "noImageFound": "No image found for this timestamp.",
+ "createObjectMask": "Create Object Mask",
+ "adjustAnnotationSettings": "Adjust annotation settings",
+ "scrollViewTips": "Scroll to view the significant moments of this object's lifecycle.",
+ "autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.",
+ "lifecycleItemDesc": {
+ "visible": "{{label}} detected",
+ "entered_zone": "{{label}} entered {{zones}}",
+ "active": "{{label}} became active",
+ "stationary": "{{label}} became stationary",
+ "attribute": {
+ "faceOrLicense_plate": "{{attribute}} detected for {{label}}",
+ "other": "{{label}} recognized as {{attribute}}"
+ },
+ "gone": "{{label}} left",
+ "heard": "{{label}} heard",
+ "external": "{{label}} detected"
+ },
+ "annotationSettings": {
+ "title": "Annotation Settings",
+ "showAllZones": "Show All Zones",
+ "showAllZones.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 annotation_offset
field can be used to adjust this.",
+ "documentation": "Read the documentation ",
+ "millisecondsToOffset": "Milliseconds to offset detect annotations by. Default: 0 ",
+ "tips": "TIP: Imagine there is an event clip with a person walking from left to right. If the event timeline bounding box is consistently to the left of the person then the value should be decreased. Similarly, if a person is walking from left to right and the bounding box is consistently ahead of the person then the value should be increased."
+ }
+ },
+ "carousel": {
+ "previous": "Previous slide",
+ "next": "Next slide"
+ }
+ },
+ "details": {
+ "item": {
+ "title": "Review Item Details",
+ "desc": "Review item details",
+ "button": {
+ "share": "Share this review item",
+ "viewInExplore": "View in Explore"
+ },
+ "tips": {
+ "mismatch_one": "{{count}} unavailable object was detected and included in this review item. Those objects either did not qualify as an alert or detection or have already been cleaned up/deleted.",
+ "mismatch_other": "{{count}} unavailable objects were detected and included in this review item. Those objects either did not qualify as an alert or detection or have already been cleaned up/deleted.",
+ "hasMissingObjects": "Adjust your configuration if you want Frigate to save tracked objects for the following labels: {{objects}} "
+ },
+ "toast": {
+ "success": {
+ "regenerate": "A new description has been requested from {{provider}}. Depending on the speed of your provider, the new description may take some time to regenerate.",
+ "updatedSublabel": "Successfully updated sub label."
+ },
+ "error": {
+ "regenerate": "Failed to call {{provider}} for a new description: {{errorMessage}}",
+ "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.",
+ "estimatedSpeed": "Estimated Speed",
+ "objects": "Objects",
+ "camera": "Camera",
+ "zones": "Zones",
+ "timestamp": "Timestamp",
+ "button": {
+ "findSimilar": "Find Similar"
+ },
+ "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",
+ "tips": {
+ "descriptionSaved": "Successfully saved description",
+ "saveDescriptionFailed": "Failed to update the description: {{errorMessage}}"
+ }
+ },
+ "itemMenu": {
+ "downloadVideo": {
+ "label": "Download video",
+ "aria": "Download video"
+ },
+ "downloadSnapshot": {
+ "label": "Download snapshot",
+ "aria": "Download snapshot"
+ },
+ "viewObjectLifecycle": {
+ "label": "View object lifecycle",
+ "aria": "Show the object lifecycle"
+ },
+ "findSimilar": {
+ "label": "Find similar",
+ "aria": "Find similar tracked objects"
+ },
+ "submitToPlus": {
+ "label": "Submit to Frigate+",
+ "aria": "Submit to Frigate Plus"
+ },
+ "viewInHistory": {
+ "label": "View in History",
+ "aria": "View in History"
+ },
+ "deleteTrackedObject": {
+ "label": "Delete this tracked object"
+ }
+ },
+ "dialog": {
+ "confirmDelete": {
+ "title": "Confirm Delete",
+ "desc": "Deleting this tracked object removes the snapshot, any saved embeddings, and any associated object lifecycle entries. Recorded footage of this tracked object in History view will NOT be deleted. Are you sure you want to proceed?"
+ }
+ },
+ "noTrackedObjects": "No Tracked Objects Found",
+ "fetchingTrackedObjectsFailed": "Error fetching tracked objects: {{errorMessage}}",
+ "trackedObjectsCount": "{{count}} tracked objects ",
+ "searchResult": {
+ "deleteTrackedObject": {
+ "toast": {
+ "success": "Tracked object deleted successfully.",
+ "error": "Failed to delete tracked object: {{errorMessage}}"
+ }
+ }
+ }
+}
diff --git a/web/public/locales/en/views/exports.json b/web/public/locales/en/views/exports.json
new file mode 100644
index 000000000..0dba02a29
--- /dev/null
+++ b/web/public/locales/en/views/exports.json
@@ -0,0 +1,18 @@
+{
+ "documentTitle": "Export - Frigate",
+ "search": "Search",
+ "noExports": "No exports found",
+ "deleteExport": "Delete Export",
+ "deleteExport.desc": "Are you sure you want to delete {{exportName}}?",
+ "editExport": {
+ "title": "Rename Export",
+ "desc": "Enter a new name for this export.",
+ "saveExport": "Save Export"
+ },
+ "toast": {
+ "error": {
+ "renameExportFailed": "Failed to rename export: {{errorMessage}}"
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json
new file mode 100644
index 000000000..566c741e2
--- /dev/null
+++ b/web/public/locales/en/views/faceLibrary.json
@@ -0,0 +1,41 @@
+{
+ "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"
+ },
+ "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}}"
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json
new file mode 100644
index 000000000..0c31d4833
--- /dev/null
+++ b/web/public/locales/en/views/live.json
@@ -0,0 +1,154 @@
+{
+ "documentTitle": "Live - Frigate",
+ "documentTitle.withCamera": "{{camera}} - Live - Frigate",
+ "lowBandwidthMode": "Low-bandwidth Mode",
+ "twoWayTalk": {
+ "enable": "Enable Two Way Talk",
+ "disable": "Disable Two Way Talk"
+ },
+ "cameraAudio": {
+ "enable": "Enable Camera Audio",
+ "disable": "Disable Camera Audio"
+ },
+ "ptz": {
+ "move": {
+ "clickMove": {
+ "label": "Click in the frame to center the camera",
+ "enable": "Enable click to move",
+ "disable": "Disable click to move"
+ },
+ "left": {
+ "label": "Move PTZ camera to the left"
+ },
+ "up": {
+ "label": "Move PTZ camera up"
+ },
+ "down": {
+ "label": "Move PTZ camera down"
+ },
+ "right": {
+ "label": "Move PTZ camera to the right"
+ }
+ },
+ "zoom": {
+ "in": {
+ "label": "Zoom PTZ camera in"
+ },
+ "out": {
+ "label": "Zoom PTZ camera out"
+ }
+ },
+ "frame": {
+ "center": {
+ "label": "Click in the frame to center the PTZ camera"
+ }
+ },
+ "presets": "PTZ camera presets"
+ },
+ "camera": {
+ "enable": "Enable Camera",
+ "disable": "Disable Camera"
+ },
+ "muteCameras": {
+ "enable": "Mute All Cameras",
+ "disable": "Unmute All Cameras"
+ },
+ "detect": {
+ "enable": "Enable Detect",
+ "disable": "Disable Detect"
+ },
+ "recording": {
+ "enable": "Enable Recording",
+ "disable": "Disable Recording"
+ },
+ "snapshots": {
+ "enable": "Enable Snapshots",
+ "disable": "Disable Snapshots"
+ },
+ "audioDetect": {
+ "enable": "Enable Audio Detect",
+ "disable": "Disable Audio Detect"
+ },
+ "autotracking": {
+ "enable": "Enable Autotracking",
+ "disable": "Disable Autotracking"
+ },
+ "streamStats": {
+ "enable": "Show Stream Stats",
+ "disable": "Hide Stream Stats"
+ },
+ "manualRecording": {
+ "title": "On-Demand Recording",
+ "tips": "Start a manual event based on this camera's recording retention settings.",
+ "playInBackground": {
+ "label": "Play in background",
+ "desc": "Enable this option to continue streaming when the player is hidden."
+ },
+ "showStats": {
+ "label": "Show Stats",
+ "desc": "Enable this option to show stream statistics as an overlay on the camera feed."
+ },
+ "debugView": "Debug View",
+ "start": "Start on-demand recording",
+ "started": "Started manual on-demand recording.",
+ "failedToStart": "Failed to start manual on-demand recording.",
+ "recordDisabledTips": "Since recording is disabled or restricted in the config for this camera, only a snapshot will be saved.",
+ "end": "End on-demand recording",
+ "ended": "Ended manual on-demand recording.",
+ "failedToEnd": "Failed to end manual on-demand recording."
+ },
+ "streamingSettings": "Streaming Settings",
+ "notifications": "Notifications",
+ "audio": "Audio",
+ "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 ",
+ "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.documentation": "Read the documentation ",
+ "available": "Two-way talk is available for this stream",
+ "unavailable": "Two-way talk is unavailable for this stream"
+ },
+ "lowBandwidth": {
+ "tips": "Live view is in low-bandwidth mode due to buffering or stream errors.",
+ "resetStream": "Reset stream"
+ },
+ "playInBackground": {
+ "label": "Play in background",
+ "tips": "Enable this option to continue streaming when the player is hidden."
+ }
+ },
+ "cameraSettings": {
+ "title": "{{camera}} Settings",
+ "cameraEnabled": "Camera Enabled",
+ "objectDetection": "Object Detection",
+ "recording": "Recording",
+ "snapshots": "Snapshots",
+ "audioDetection": "Audio Detection",
+ "autotracking": "Autotracking"
+ },
+ "history": {
+ "label": "Show historical footage"
+ },
+ "effectiveRetainMode": {
+ "modes": {
+ "all": "All",
+ "motion": "Motion",
+ "active_objects": "Active Objects"
+ },
+ "notAllTips": "Your {{source}} recording retention configuration is set to mode: {{effectiveRetainMode}}
, so this on-demand recording will only keep segments with {{effectiveRetainModeName}}."
+ },
+ "editLayout": {
+ "label": "Edit Layout",
+ "group.label": "Edit Camera Group",
+ "exitEdit": "Exit Editing"
+ }
+}
diff --git a/web/public/locales/en/views/recording.json b/web/public/locales/en/views/recording.json
new file mode 100644
index 000000000..9033d7819
--- /dev/null
+++ b/web/public/locales/en/views/recording.json
@@ -0,0 +1,12 @@
+{
+ "export": "Export",
+ "calendar": "Calendar",
+ "filter": "Filter",
+ "filters": "Filters",
+ "toast": {
+ "error": {
+ "noValidTimeSelected": "No valid time range selected",
+ "endTimeMustAfterStartTime": "End time must be after start time"
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/en/views/search.json b/web/public/locales/en/views/search.json
new file mode 100644
index 000000000..b63508fe9
--- /dev/null
+++ b/web/public/locales/en/views/search.json
@@ -0,0 +1,65 @@
+{
+ "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"
+ },
+ "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": "
Type a filter name followed by a colon (e.g., \"cameras:\"). Select a value from the suggestions or type your own. Use multiple filters by adding them one after another with a space in between. Date filters (before: and after:) use {{DateFormat}} format. Time range filter uses {{exampleTime}} format. Remove filters by clicking the 'x' next to them. ",
+ "desc.example": "Example: cameras:front_door label:person before:01012024 time_range:3:00PM-4:00PM
"
+ },
+ "header": {
+ "currentFilterType": "Filter Values",
+ "noFilters": "Filters",
+ "activeFilters": "Active Filters"
+ }
+ },
+ "similaritySearch": {
+ "title": "Similarity Search",
+ "active": "Similarity search active",
+ "clear": "Clear similarity search"
+ },
+ "placeholder": {
+ "search": "Search..."
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
new file mode 100644
index 000000000..3d4b6900c
--- /dev/null
+++ b/web/public/locales/en/views/settings.json
@@ -0,0 +1,435 @@
+{
+ "documentTitle": {
+ "default": "Settings - Frigate",
+ "authentication": "Authentication Settings - Frigate",
+ "camera": "Camera Settings - Frigate",
+ "classification": "Classification Settings - Frigate",
+ "masksAndZones": "Mask and Zone Editor - Frigate",
+ "motionTuner": "Motion Tuner - Frigate",
+ "object": "Object Settings - Frigate",
+ "general": "General Settings - Frigate"
+ },
+ "menu": {
+ "uiSettings": "UI Settings",
+ "classificationSettings": "Classification Settings",
+ "cameraSettings": "Camera Settings",
+ "masksAndZones": "Masks / Zones",
+ "motionTuner": "Motion Tuner",
+ "debug": "Debug",
+ "users": "Users",
+ "notifications": "Notifications"
+ },
+ "dialog": {
+ "unsavedChanges": {
+ "title": "You have unsaved changes.",
+ "desc": "Do you want to save your changes before continuing?"
+ }
+ },
+ "cameraSetting": {
+ "camera": "Camera",
+ "noCamera": "No Camera"
+ },
+ "general": {
+ "title": "General Settings",
+ "liveDashboard": {
+ "title": "Live Dashboard",
+ "automaticLiveView": {
+ "label": "Automatic Live View",
+ "desc": "Automatically switch to a camera's live view when activity is detected. Disabling this option causes static camera images on the Live dashboard to only update once per minute."
+ },
+ "playAlertVideos": {
+ "label": "Play Alert Videos",
+ "desc": "By default, recent alerts on the Live dashboard play as small looping videos. Disable this option to only show a static image of recent alerts on this device/browser."
+ }
+ },
+ "storedLayouts": {
+ "title": "Stored Layouts",
+ "desc": "The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's local storage.",
+ "clearAll": "Clear All Layouts"
+ },
+ "cameraGroupStreaming": {
+ "title": "Camera Group Streaming Settings",
+ "desc": "Streaming settings for each camera group are stored in your browser's local storage.",
+ "clearAll": "Clear All Streaming Settings"
+ },
+ "recordingsViewer": {
+ "title": "Recordings Viewer",
+ "defaultPlaybackRate": {
+ "label": "Default Playback Rate",
+ "desc": "Default playback rate for recordings playback."
+ }
+ },
+ "calendar": {
+ "title": "Calendar",
+ "firstWeekday": {
+ "label": "First Weekday",
+ "desc": "The day that the weeks of the review calendar begin on.",
+ "sunday": "Sunday",
+ "monday": "Monday"
+ }
+ },
+ "toast": {
+ "success": {
+ "clearStoredLayout": "Cleared stored layout for {{cameraName}}",
+ "clearStreamingSettings": "Cleared streaming settings for all camera groups."
+ },
+ "error": {
+ "clearStoredLayoutFailed": "Failed to clear stored layout: {{errorMessage}}",
+ "clearStreamingSettingsFailed": "Failed to clear streaming settings: {{errorMessage}}"
+ }
+ }
+ },
+ "classification": {
+ "title": "Classification Settings",
+ "semanticSearch": {
+ "title": "Semantic Search",
+ "desc": "Semantic Search in Frigate allows you to find tracked objects within your review items using either the image itself, a user-defined text description, or an automatically generated one.",
+ "readTheDocumentation": "Read the Documentation",
+ "reindexOnStartup": {
+ "label": "Re-Index On Startup",
+ "desc": "Re-indexing will reprocess all thumbnails and descriptions (if enabled) and apply the embeddings on each startup. Don't forget to disable the option after restarting! "
+ },
+ "modelSize": {
+ "label": "Model Size",
+ "desc": "The size of the model used for semantic search embeddings.",
+ "small": "small",
+ "large": "large",
+ "small.desc": "Using small 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 large employs the full Jina model and will automatically run on the GPU if applicable."
+ }
+ },
+ "faceRecognition": {
+ "title": "Face Recognition",
+ "desc": "Face recognition allows people to be assigned names and when their face is recognized Frigate will assign the person's name as a sub label. This information is included in the UI, filters, as well as in notifications.",
+ "readTheDocumentation": "Read the Documentation"
+ },
+ "licensePlateRecognition": {
+ "title": "License Plate Recognition",
+ "desc": "Frigate can recognize license plates on vehicles and automatically add the detected characters to the recognized_license_plate field or a known name as a sub_label to objects that are of type car. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.",
+ "readTheDocumentation": "Read the Documentation"
+ },
+ "toast": {
+ "success": "Classification settings have been saved.",
+ "error": "Failed to save config changes: {{errorMessage}}"
+ }
+ },
+ "camera": {
+ "title": "Camera Settings",
+ "streams": {
+ "title": "Streams",
+ "desc": "Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable. Note: This does not disable go2rtc restreams. "
+ },
+ "review": {
+ "title": "Review",
+ "desc": "Enable/disable alerts and detections for this camera. When disabled, no new review items will be generated.",
+ "alerts": "Alerts ",
+ "detections": "Detections "
+ },
+ "reviewClassification": {
+ "title": "Review Classification",
+ "desc": "Frigate categorizes review items as Alerts and Detections. By default, all person and car objects are considered Alerts. You can refine categorization of your review items by configuring required zones for them.",
+ "readTheDocumentation": "Read the Documentation",
+ "noDefinedZones": "No zones are defined for this camera.",
+ "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.",
+ "selectAlertsZones": "Select zones for Alerts",
+ "selectDetectionsZones": "Select zones for Detections",
+ "limitDetections": "Limit detections to specific zones",
+ "toast": {
+ "success": "Review classification configuration has been saved. Restart Frigate to apply changes."
+ }
+ }
+ },
+ "masksAndZones": {
+ "filter": {
+ "all": "All Masks and Zones"
+ },
+ "toast": {
+ "success": {
+ "copyCoordinates": "Copied coordinates for {{polyName}} to clipboard."
+ },
+ "error": {
+ "copyCoordinatesFailed": "Could not copy coordinates to clipboard."
+ }
+ },
+ "form": {
+ "zoneName": {
+ "error": {
+ "mustBeAtLeastTwoCharacters": "Zone name must be at least 2 characters.",
+ "mustNotBeSameWithCamera": "Zone name must not be the same as camera name.",
+ "alreadyExists": "A zone with this name already exists for this camera.",
+ "mustNotContainPeriod": "Zone name must not contain periods.",
+ "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.",
+ "polygonDrawing": {
+ "removeLastPoint": "Remove last point",
+ "reset.label": "Clear all points",
+ "snapPoints": {
+ "true": "Snap points",
+ "false": "Don't Snap points"
+ },
+ "delete": {
+ "title": "Confirm Delete",
+ "desc": "Are you sure you want to delete the {{type}} {{name}} ?",
+ "success": "{{name}} has been deleted."
+ },
+ "error": {
+ "mustBeFinished": "Polygon drawing must be finished before saving."
+ }
+ }
+ },
+ "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",
+ "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. Default: 3 ",
+ "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. Default: 0 ",
+ "objects": "Objects",
+ "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."
+ },
+ "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",
+ "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 very sparingly , over-masking will make it more difficult for objects to be tracked.",
+ "context.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."
+ },
+ "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",
+ "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."
+ }
+ },
+ "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. Default: 30 ",
+ "contourArea": "Contour Area",
+ "contourArea.desc": "The contour area value is used to decide which groups of changed pixels qualify as motion. Default: 10 ",
+ "improveContrast": "Improve Contrast",
+ "improveContrast.desc": "Improve contrast for darker scenes. Default: ON ",
+ "toast": {
+ "success": "Motion settings have been saved."
+ }
+ },
+ "debug": {
+ "title": "Debug",
+ "detectorDesc": "Frigate uses your detectors ({{detectors}}) to detect objects in your camera's video stream.",
+ "desc": "Debugging view shows a real-time view of tracked objects and their statistics. The object list shows a time-delayed summary of detected objects.",
+ "debugging": "Debugging",
+ "objectList": "Object List",
+ "noObjects": "No objects",
+ "boundingBoxes": {
+ "title": "Bounding boxes",
+ "desc": "Show bounding boxes around tracked objects",
+ "colors": {
+ "label": "Object Bounding Box Colors",
+ "info": "At startup, different colors will be assigned to each object label A dark blue thin line indicates that object is not detected at this current point in time A gray thin line indicates that object is detected as being stationary A thick line indicates that object is the subject of autotracking (when enabled) "
+ }
+ },
+ "timestamp": {
+ "title": "Timestamp",
+ "desc": "Overlay a timestamp on the image"
+ },
+ "zones": {
+ "title": "Zones",
+ "desc": "Show an outline of any defined zones"
+ },
+ "mask": {
+ "title": "Motion masks",
+ "desc": "Show motion mask polygons"
+ },
+ "motion": {
+ "title": "Motion boxes",
+ "desc": "Show boxes around areas where motion is detected",
+ "tips": "Motion Boxes
Red boxes will be overlaid on areas of the frame where motion is currently being detected
"
+ },
+ "regions": {
+ "title": "Regions",
+ "desc": "Show a box of the region of interest sent to the object detector",
+ "tips": "Region Boxes
Bright green boxes will be overlaid on areas of interest in the frame that are being sent to the object detector.
"
+ },
+ "objectShapeFilterDrawing": {
+ "title": "Object Shape Filter Drawing",
+ "desc": "Draw a rectangle on the image to view area and ratio details",
+ "tips": "Enable this option to draw a rectangle on the camera image to show its area and ratio. These values can then be used to set object shape filter parameters in your config.",
+ "document": "Read the documentation ",
+ "score": "Score",
+ "ratio": "Ratio",
+ "area": "Area"
+ }
+ },
+ "users": {
+ "title": "Users",
+ "management": "Users Management",
+ "management.desc": "Manage this Frigate instance's user accounts.",
+ "addUser": "Add User",
+ "updatePassword": "Update Password",
+ "toast": {
+ "success": {
+ "createUser": "User {{user}} created successfully",
+ "deleteUser": "User {{user}} deleted successfully",
+ "updatePassword": "Password updated successfully.",
+ "roleUpdated": "Role updated for {{user}}"
+ },
+ "error": {
+ "setPasswordFailed": "Failed to save password: {{errorMessage}}",
+ "createUserFailed": "Failed to create user: {{errorMessage}}",
+ "deleteUserFailed": "Failed to delete user: {{errorMessage}}",
+ "roleUpdateFailed": "Failed to update role: {{errorMessage}}"
+ }
+ },
+ "table": {
+ "username": "Username",
+ "actions": "Actions",
+ "role": "Role",
+ "noUsers": "No users found.",
+ "changeRole": "Change user role",
+ "password": "Password",
+ "deleteUser": "Delete user"
+ },
+ "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",
+ "usernameIsRequired": "Username is required"
+ },
+ "createUser": {
+ "title": "Create New User",
+ "desc": "Add a new user account and specify an role for access to areas of the Frigate UI.",
+ "usernameOnlyInclude": "Username may only include letters, numbers, . or _"
+ },
+ "deleteUser": {
+ "title": "Delete User",
+ "desc": "This action cannot be undone. This will permanently delete the user account and remove all associated data.",
+ "warn": "Are you sure you want to delete {{username}} ?"
+ },
+ "passwordSetting": {
+ "updatePassword": "Update Password for {{username}}",
+ "setPassword": "Set Password",
+ "desc": "Create a strong password to secure this account."
+ },
+ "changeRole": {
+ "title": "Change User Role",
+ "desc": "Update permissions for {{username}} ",
+ "roleInfo": "Select the appropriate role for this user:
• Admin: Full access to all features. • Viewer: Limited to Live dashboards, Review, Explore, and Exports only. "
+ }
+ }
+ },
+ "notification": {
+ "title": "Notifications",
+ "notificationSettings": {
+ "title": "Notification Settings",
+ "desc": "Frigate can natively send push notifications to your device when it is running in the browser or installed as a PWA.",
+ "documentation": "Read the Documentation"
+ },
+ "notificationUnavailable": {
+ "title": "Notifications Unavailable",
+ "desc": "Web push notifications require a secure context (https://...
). This is a browser limitation. Access Frigate securely to use notifications.",
+ "documentation": "Read the Documentation"
+ },
+ "globalSettings": {
+ "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.",
+ "deviceSpecific": "Device Specific Settings",
+ "registerDevice": "Register This Device",
+ "unregisterDevice": "Unregister This Device",
+ "sendTestNotification": "Send a test notification",
+ "active": "Notifications Active",
+ "suspended": "Notifications suspended {{time}}",
+ "suspendTime": {
+ "5minutes": "Suspend for 5 minutes",
+ "10minutes": "Suspend for 10 minutes",
+ "30minutes": "Suspend for 30 minutes",
+ "1hour": "Suspend for 1 hour",
+ "12hours": "Suspend for 12 hours",
+ "24hours": "Suspend for 24 hours",
+ "untilRestart": "Suspend until restart"
+ },
+ "cancelSuspension": "Cancel Suspension",
+ "toast": {
+ "success": {
+ "registered": "Successfully registered for notifications. Restarting Frigate is required before any notifications (including a test notification) can be sent.",
+ "settingSaved": "Notification settings have been saved."
+ },
+ "error": {
+ "registerFailed": "Failed to save notification registration."
+ }
+ }
+ }
+}
diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json
new file mode 100644
index 000000000..cbcf80995
--- /dev/null
+++ b/web/public/locales/en/views/system.json
@@ -0,0 +1,144 @@
+{
+ "documentTitle": {
+ "cameras": "Cameras Stats - Frigate",
+ "storage": "Storage Stats - Frigate",
+ "general": "General Stats - Frigate",
+ "features": "Features Stats- Frigate",
+ "logs": {
+ "frigate": "Frigate Logs - Frigate",
+ "go2rtc": "Go2RTC Logs - Frigate",
+ "nginx": "Nginx Logs - Frigate"
+ }
+ },
+ "title": "System",
+ "metrics": "System metrics",
+ "logs": {
+ "download": {
+ "label": "Download Logs"
+ },
+ "copy": {
+ "label": "Copy to Clipboard",
+ "success": "Copied logs to clipboard",
+ "error": "Could not copy logs to clipboard"
+ },
+ "type": {
+ "label": "Type",
+ "timestamp": "Timestamp",
+ "tag": "Tag",
+ "message": "Message"
+ },
+ "tips": "Logs are streaming from the server",
+ "toast": {
+ "error": {
+ "fetchingLogsFailed": "Error fetching logs: {{errorMessage}}",
+ "whileStreamingLogs": "Error while streaming logs: {{errorMessage}}"
+ }
+ }
+ },
+ "general": {
+ "title": "General",
+ "detector": {
+ "title": "Detectors",
+ "inferenceSpeed": "Detector Inference Speed",
+ "cpuUsage": "Detector CPU Usage",
+ "memoryUsage": "Detector Memory Usage"
+ },
+ "hardwareInfo": {
+ "title": "Hardware Info",
+ "gpuUsage": "GPU Usage",
+ "gpuMemory": "GPU Memory",
+ "gpuEncoder": "GPU Encoder",
+ "gpuDecoder": "GPU Decoder",
+ "gpuInfo": {
+ "vainfoOutput": {
+ "title": "Vainfo Output",
+ "returnCode": "Return Code: {{code}}",
+ "processOutput": "Process Output:",
+ "processError": "Process Error:"
+ },
+ "nvidiaSMIOutput": {
+ "title": "Nvidia SMI Output",
+ "name": "Name: {{name}}",
+ "driver": "Driver: {{driver}}",
+ "cudaComputerCapability": "CUDA Compute Capability: {{cuda_compute}}",
+ "vbios": "VBios Info: {{vbios}}"
+ },
+ "closeInfo.label": "Close GPU info",
+ "copyInfo.label": "Copy GPU info",
+ "toast": {
+ "success": "Copied GPU info to clipboard"
+ }
+ }
+ },
+ "otherProcesses": {
+ "title": "Other Processes",
+ "processCpuUsage": "Process CPU Usage",
+ "processMemoryUsage": "Process Memory Usage"
+ }
+ },
+ "storage": {
+ "title": "Storage",
+ "overview": "Overview",
+ "recordings": {
+ "title": "Recordings",
+ "tips": "This value represents the total storage used by the recordings in Frigate's database. Frigate does not track storage usage for all files on your disk.",
+ "earliestRecording": "Earliest recording available:"
+ },
+ "cameraStorage": {
+ "title": "Camera Storage",
+ "camera": "Camera",
+ "unused": "Unused",
+ "unusedStorageInformation": "Unused Storage Information",
+ "storageUsed": "Storage Used",
+ "percentageOfTotalUsed": "Percentage of Total Used",
+ "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."
+ }
+ },
+ "cameras": {
+ "title": "Cameras",
+ "overview": "Overview",
+ "info": {
+ "cameraProbeInfo": "{{camera}} Camera Probe Info",
+ "streamDataFromFFPROBE": "Stream data is obtained with ffprobe
.",
+ "fetching": "Fetching Camera Data",
+ "stream": "Stream {{idx}}",
+ "video": "Video:",
+ "codec": "Codec:",
+ "resolution": "Resolution:",
+ "fps": "FPS:",
+ "unknown": "Unknown",
+ "audio": "Audio:",
+ "error": "Error: {{error}}",
+ "tips": {
+ "title": "Camera Probe Info"
+ }
+ },
+ "framesAndDetections": "Frames / Detections",
+ "label": {
+ "camera": "camera",
+ "detect": "detect",
+ "skipped": "skipped",
+ "ffmpeg": "ffmpeg",
+ "capture": "capture"
+ },
+ "toast": {
+ "success": {
+ "copyToClipboard": "Copied probe data to clipboard."
+ },
+ "error": {
+ "unableToProbeCamera": "Unable to probe camera: {{errorMessage}}"
+ }
+ }
+ },
+ "lastRefreshed": "Last refreshed: ",
+ "stats": {
+ "ffmpegHighCpuUsage": "{{camera}} has high FFMPEG CPU usage ({{ffmpegAvg}}%)",
+ "detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)",
+ "healthy": "System is healthy",
+ "reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)"
+ },
+ "features": {
+ "title": "Features"
+ }
+}
diff --git a/web/public/locales/zh-CN/audio.json b/web/public/locales/zh-CN/audio.json
new file mode 100644
index 000000000..f9438a747
--- /dev/null
+++ b/web/public/locales/zh-CN/audio.json
@@ -0,0 +1,8 @@
+{
+ "crying": "哭泣",
+ "laughter": "笑声",
+ "scream": "尖叫",
+ "speech": "谈话",
+ "yell": "大喊",
+ "fire_alarm": "火灾警报器"
+}
diff --git a/web/public/locales/zh-CN/common.json b/web/public/locales/zh-CN/common.json
new file mode 100644
index 000000000..16944e264
--- /dev/null
+++ b/web/public/locales/zh-CN/common.json
@@ -0,0 +1,173 @@
+{
+ "time": {
+ "untilForTime": "直到 {{time}}",
+ "untilForRestart": "直到 Frigate 重启。",
+ "untilRestart": "直到重启",
+ "ago": "{{timeAgo}} 前",
+ "justNow": "刚才",
+ "today": "今天",
+ "yesterday": "昨天",
+ "last7": "最后 7 天",
+ "last14": "最后 14 天",
+ "last30": "最后 30 天",
+ "thisWeek": "本周",
+ "lastWeek": "上个周",
+ "thisMonth": "本月",
+ "lastMonth": "上个月",
+ "5minutes": "5 分钟",
+ "10minutes": "10 分钟",
+ "30minutes": "30 分钟",
+ "1hour": "1 小时",
+ "12hours": "12 小时",
+ "24hours": "24 小时",
+ "pm": "上午",
+ "am": "下午",
+ "yr": "{{time}}年",
+ "year": "{{time}}年",
+ "mo": "{{time}}月",
+ "month": "{{time}}月",
+ "d": "{{time}}天",
+ "day": "{{time}}天",
+ "h": "{{time}}小时",
+ "hour": "{{time}}小时",
+ "m": "{{time}}分钟",
+ "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",
+ "formattedTimestampOnlyMonthAndDay": "%m月%-d日"
+ },
+ "unit": {
+ "speed": {
+ "mph": "英里/小时",
+ "kph": "公里/小时"
+ }
+ },
+ "label": {
+ "back": "返回"
+ },
+ "pagination": {
+ "label": "分页",
+ "previous": "上一页",
+ "previous.label": "转到上一页",
+ "next": "下一页",
+ "next.label": "转到下一页",
+ "more": "更多页面"
+ },
+ "button": {
+ "apply": "应用",
+ "reset": "重置",
+ "enabled": "启用",
+ "enable": "启用",
+ "disabled": "禁用",
+ "disable": "禁用",
+ "save": "保存",
+ "saving": "保存中……",
+ "cancel": "取消",
+ "close": "关闭",
+ "copy": "复制",
+ "back": "返回",
+ "history": "历史",
+ "fullscreen": "全屏",
+ "exitFullscreen": "全屏",
+ "pictureInPicture": "画中画",
+ "on": "开",
+ "off": "关",
+ "edit": "编辑",
+ "copyCoordinates": "复制坐标",
+ "delete": "删除",
+ "yes": "是",
+ "no": "否",
+ "download": "下载",
+ "info": "信息",
+ "suspended": "已暂停",
+ "unsuspended": "取消暂停",
+ "play": "播放",
+ "unselect": "取消选择",
+ "export": "导出",
+ "deleteNow": "立即删除"
+ },
+ "menu": {
+ "system": "系统",
+ "systemMetrics": "系统信息",
+ "configuration": "配置",
+ "systemLogs": "系统日志",
+ "settings": "设置",
+ "configurationEditor": "配置编辑器",
+ "languages": "languages / 语言",
+ "language": {
+ "en": "English",
+ "zhCN": "简体中文",
+ "withSystem.label": "使用系统语言设置"
+ },
+ "appearance": "外观",
+ "darkMode": {
+ "label": "深色模式",
+ "light": "浅色",
+ "dark": "深色",
+ "withSystem.label": "使用系统深色模式设置"
+ },
+ "withSystem": "跟随系统",
+ "theme": {
+ "label": "主题",
+ "blue": "蓝色",
+ "green": "绿色",
+ "nord": "Nord",
+ "red": "红色",
+ "contrast": "高对比度",
+ "default": "默认"
+ },
+ "help": "帮助",
+ "documentation.label": "Frigate 的官方文档",
+ "documentation": "文档",
+ "live": "实时监控",
+ "live.allCameras": "所有摄像头",
+ "live.cameras": "摄像头",
+ "live.cameras.count_one": "{{count}} 个摄像头",
+ "live.cameras.count_other": "{{count}} 个摄像头",
+ "review": "回放",
+ "explore": "探测",
+ "export": "导出",
+ "uiPlayground": "UI Playground",
+ "faceLibrary": "人脸管理",
+ "user": {
+ "account": "账号",
+ "current": "当前用户:{{user}}",
+ "anonymous": "匿名",
+ "logout": "登出",
+ "setPassword": "设置密码"
+ },
+ "restart": "重启 Frigate"
+ },
+ "toast": {
+ "copyUrlToClipboard": "已复制链接到剪贴板。",
+ "save": {
+ "error": "保存配置信息失败: {{errorMessage}}",
+ "error.noMessage": "保存配置信息失败"
+ }
+ },
+ "role": {
+ "title": "权限组",
+ "admin": "管理员",
+ "viewer": "查看者",
+ "desc": "管理员可以完全访问 Frigate UI 的所有功能。查看者则仅限于在 UI 中查看摄像头、审核项和历史录像。"
+ },
+ "accessDenied": {
+ "documentTitle": "没有权限 - Frigate",
+ "title": "没有权限",
+ "desc": "您没有权限查看此页面。"
+ },
+ "notFound": {
+ "documentTitle": "没有找到页面 - Frigate",
+ "title": "404",
+ "desc": "页面未找到"
+ },
+ "selectItem": "选择 {{item}}"
+}
diff --git a/web/public/locales/zh-CN/components/auth.json b/web/public/locales/zh-CN/components/auth.json
new file mode 100644
index 000000000..3e9a163b7
--- /dev/null
+++ b/web/public/locales/zh-CN/components/auth.json
@@ -0,0 +1,15 @@
+{
+ "form": {
+ "user": "用户名",
+ "password": "密码",
+ "login": "登录",
+ "errors": {
+ "usernameRequired": "用户名不能为空",
+ "passwordRequired": "密码不能为空",
+ "rateLimit": "超出请求限制,请稍后再试。",
+ "loginFailed": "登录失败",
+ "unknownError": "未知错误,请检查日志。",
+ "webUnkownError": "未知错误,请检查控制台日志。"
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/zh-CN/components/camera.json b/web/public/locales/zh-CN/components/camera.json
new file mode 100644
index 000000000..c8a95adec
--- /dev/null
+++ b/web/public/locales/zh-CN/components/camera.json
@@ -0,0 +1,77 @@
+{
+ "group": {
+ "label": "摄像头组",
+ "add": "添加摄像头组",
+ "edit": "编辑摄像头组",
+ "delete": {
+ "label": "删除摄像头组",
+ "confirm": "确认删除",
+ "confirm.desc": "你确定要删除摄像头组 {{name}} 吗?"
+ },
+ "name": {
+ "label": "名称",
+ "placeholder": "请输入名称",
+ "error": {
+ "mustLeastCharacters": "摄像头组的名称必须至少有 2 个字符。",
+ "exists": "摄像头组名称已存在。",
+ "nameMustNotPeriod": "摄像头组名称不能包含英文句号(.)。",
+ "invalid": "无效的摄像头组名称。"
+ }
+ },
+ "cameras": {
+ "label": "摄像头",
+ "desc": "选择添加至该组的摄像头。"
+ },
+ "icon": "图标",
+ "success": "摄像头组({{name}})保存成功。",
+ "camera": {
+ "setting": {
+ "label": "摄像头视频流设置",
+ "title": "{{cameraName}} 视频流设置",
+ "desc": "更改此摄像头组仪表板的实时视频流选项。这些设置特定于设备/浏览器。 ",
+ "audioIsAvailable": "此视频流支持音频",
+ "audioIsUnavailable": "此视频流不支持音频",
+ "audio": {
+ "tips": "音频必须从您的摄像头输出并在 go2rtc 中配置此流。",
+ "tips.document": "阅读文档(英文) "
+ },
+ "streamMethod": {
+ "label": "视频流方法",
+ "method": {
+ "noStreaming": {
+ "label": "无视频流",
+ "desc": "摄像头图像每分钟仅更新一次,不会进行实时视频流播放。"
+ },
+ "smartStreaming": {
+ "label": "智能视频流(推荐)",
+ "desc": "智能视频流在没有检测到活动时,每分钟更新一次摄像头图像,以节省带宽和资源。当检测到活动时,图像会无缝切换到实时视频流。"
+ },
+ "continuousStreaming": {
+ "label": "持续视频流",
+ "desc": "当摄像头画面在仪表板上可见时,始终为实时视频流,即使未检测到活动。",
+ "desc.warning": "持续视频流可能会导致高带宽使用和性能问题,请谨慎使用。"
+ }
+ }
+ },
+ "compatibilityMode": {
+ "label": "兼容模式",
+ "desc": "仅在摄像头的实时视频流显示颜色伪影,并且图像右侧有一条对角线时启用此选项。"
+ }
+ }
+ }
+ },
+ "debug": {
+ "options": {
+ "label": "设置",
+ "title": "选项",
+ "showOptions": "显示选项",
+ "hideOptions": "隐藏选项"
+ },
+ "boundingBox": "边界框",
+ "timestamp": "时间戳",
+ "zones": "区域",
+ "mask": "遮罩",
+ "motion": "运动",
+ "regions": "区域"
+ }
+}
diff --git a/web/public/locales/zh-CN/components/dialog.json b/web/public/locales/zh-CN/components/dialog.json
new file mode 100644
index 000000000..54a669f17
--- /dev/null
+++ b/web/public/locales/zh-CN/components/dialog.json
@@ -0,0 +1,96 @@
+{
+ "restart": {
+ "title": "你确定要重启 Frigate?",
+ "button": "重启",
+ "restarting": {
+ "title": "Frigate 正在重启",
+ "content": "该页面将会在 {{countdown}} 秒后自动刷新。",
+ "button": "强制刷新"
+ }
+ },
+ "explore": {
+ "plus": {
+ "submitToPlus": {
+ "label": "提交至 Frigate+",
+ "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": "已提交"
+ }
+ },
+ "video": {
+ "viewInHistory": "在历史中查看"
+ }
+ },
+ "export": {
+ "time": {
+ "fromTimeline": "从时间线选择",
+ "lastHour_one": "最后1小时",
+ "lastHour_other": "最后 {{count}} 小时",
+ "custom": "自定义",
+ "start": "开始时间",
+ "start.label": "选择开始时间",
+ "end": "结束时间",
+ "end.label": "选择结束时间"
+ },
+ "name": {
+ "placeholder": "导出项目的名字"
+ },
+ "select": "选择",
+ "export": "导出",
+ "selectOrExport": "选择或导出",
+ "toast": {
+ "success": "导出成功。进入 /exports 目录查看文件。",
+ "error": {
+ "failed": "导出失败:{{error}}",
+ "endTimeMustAfterStartTime": "结束时间必须在开始时间之后",
+ "noVaildTimeSelected": "未选择有效的时间范围"
+ }
+ },
+ "fromTimeline": {
+ "saveExport": "保存导出",
+ "previewExport": "预览导出"
+ }
+ },
+ "streaming": {
+ "label": "视频流",
+ "restreaming": {
+ "NotEnabled": "重新流式传输未启用。",
+ "desc": "为此摄像头设置 go2rtc,以获取额外的实时预览选项和音频支持。",
+ "desc.readTheDocumentation": "阅读文档(英文) "
+ },
+ "showStats": {
+ "label": "显示视频流统计信息",
+ "desc": "启用后将在摄像头画面上叠加显示视频流统计信息。"
+ },
+ "debugView": "调试界面"
+ },
+ "search": {
+ "saveSearch": {
+ "label": "保存搜索",
+ "desc": "请为此已保存的搜索提供一个名称。",
+ "placeholder": "请输入搜索名称",
+ "overwrite": "{{searchName}} 已存在。保存将覆盖现有值。",
+ "success": "搜索 ({{searchName}}) 已保存。",
+ "button.save.label": "保存此搜索"
+ }
+ },
+ "recording": {
+ "confirmDelete": {
+ "title": "确认删除",
+ "desc": "您确定要删除与此审核项相关的所有录制视频吗? 提示:按住 Shift 键点击删除可跳过此对话框。",
+ "desc.selected": "您确定要删除与此审核项相关的所有录制视频吗? 提示:按住 Shift 键点击删除可跳过此对话框。"
+ },
+ "button": {
+ "export": "导出",
+ "markAsReviewed": "标记为已审核",
+ "deleteNow": "立即删除"
+ }
+ }
+}
diff --git a/web/public/locales/zh-CN/components/filter.json b/web/public/locales/zh-CN/components/filter.json
new file mode 100644
index 000000000..62ebf5093
--- /dev/null
+++ b/web/public/locales/zh-CN/components/filter.json
@@ -0,0 +1,108 @@
+{
+ "filter": "过滤器",
+ "labels": {
+ "label": "标签",
+ "all": "所有标签",
+ "all.short": "标签",
+ "count": "{{count}} 个标签"
+ },
+ "zones": {
+ "all": "所有区域",
+ "all.short": "区域"
+ },
+ "dates": {
+ "all": "所有日期",
+ "all.short": "日期"
+ },
+ "more": "更多筛选项",
+ "reset.label": "重置筛选器为默认值",
+ "timeRange": "时间范围",
+ "zones.label": "区域",
+ "subLabels": {
+ "label": "子标签",
+ "all": "所有子标签"
+ },
+ "score": "分值",
+ "estimatedSpeed": "预计速度({{unit}})",
+ "features": {
+ "label": "特性",
+ "hasSnapshot": "包含快照",
+ "hasVideoClip": "包含视频片段",
+ "submittedToFrigatePlus": {
+ "label": "提交至 Frigate+",
+ "tips": "你必须要先筛选具有快照的探测对象。 没有快照的跟踪对象无法提交至 Frigate+."
+ }
+ },
+ "sort": {
+ "label": "排序",
+ "dateAsc": "日期 (正序)",
+ "dateDesc": "日期 (倒序)",
+ "scoreAsc": "对象分值 (正序)",
+ "scoreDesc": "对象分值 (倒序)",
+ "speedAsc": "预计速度 (正序)",
+ "speedDesc": "预计速度 (倒序)",
+ "relevance": "关联性"
+ },
+ "cameras": {
+ "label": "摄像头筛选",
+ "all": "所有摄像头",
+ "all.short": "摄像头"
+ },
+ "review": {
+ "showReviewed": "显示已查看的项目"
+ },
+ "motion": {
+ "showMotionOnly": "仅显示运动"
+ },
+ "explore": {
+ "settings": {
+ "title": "设置",
+ "defaultView": "默认视图",
+ "defaultView.desc": "当未选择任何过滤器时,显示每个标签最近跟踪对象的摘要,或显示未过滤的网格。",
+ "defaultView.summary": "摘要",
+ "defaultView.unfilteredGrid": "未过滤网格",
+ "gridColumns": "网格列数",
+ "gridColumns.desc": "选择网格视图中的列数。",
+ "searchSource": {
+ "label": "搜索源",
+ "desc": "选择是搜索缩略图还是跟踪对象的描述。",
+ "options": {
+ "thumbnailImage": "缩略图",
+ "description": "描述"
+ }
+ },
+ "date": {
+ "selectDateBy": {
+ "label": "选择日期进行筛选"
+ }
+ }
+ }
+ },
+ "logSettings": {
+ "label": "日志级别筛选",
+ "filterBySeverity": "按严重程度筛选日志",
+ "loading": "加载中",
+ "loading.desc": "当日志面板滚动到底部时,新的日志会自动流式加载。",
+ "disableLogStreaming": "禁用日志流式加载",
+ "allLogs": "所有日志"
+ },
+ "trackedObjectDelete": {
+ "title": "确认删除",
+ "desc": "删除这 {{objectLength}} 个跟踪对象将移除快照、任何已保存的嵌入和任何相关的对象生命周期条目。历史视图中这些跟踪对象的录制片段将不会 被删除。 您确定要继续吗? 按住 Shift 键可在将来跳过此对话框。",
+ "toast": {
+ "success": "跟踪对象删除成功。",
+ "error": "删除跟踪对象失败:{{errorMessage}}"
+ }
+ },
+ "zoneMask": {
+ "filterBy": "按区域遮罩筛选"
+ },
+ "recognizedLicensePlates": {
+ "title": "识别的车牌",
+ "loadFailed": "加载识别的车牌失败。",
+ "loading": "正在加载识别的车牌...",
+ "placeholder": "输入以搜索车牌...",
+ "noLicensePlatesFound": "未找到车牌。",
+ "selectPlatesFromList": "从列表中选择一个或多个车牌。"
+ }
+}
diff --git a/web/public/locales/zh-CN/components/icons.json b/web/public/locales/zh-CN/components/icons.json
new file mode 100644
index 000000000..93b486319
--- /dev/null
+++ b/web/public/locales/zh-CN/components/icons.json
@@ -0,0 +1,8 @@
+{
+ "iconPicker": {
+ "selectIcon": "选择图标",
+ "search": {
+ "placeholder": "搜索图标..."
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/zh-CN/components/input.json b/web/public/locales/zh-CN/components/input.json
new file mode 100644
index 000000000..3b4478076
--- /dev/null
+++ b/web/public/locales/zh-CN/components/input.json
@@ -0,0 +1,10 @@
+{
+ "button": {
+ "downloadVideo": {
+ "label": "下载视频",
+ "toast": {
+ "success": "下载成功"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/zh-CN/components/player.json b/web/public/locales/zh-CN/components/player.json
new file mode 100644
index 000000000..5b70fd856
--- /dev/null
+++ b/web/public/locales/zh-CN/components/player.json
@@ -0,0 +1,39 @@
+{
+ "noRecordingsFoundForThisTime": "找不到此次录制",
+ "noPreviewFound": "没有找到预览",
+ "noPreviewFoundFor": "没有在 {{cameraName}} 下找到预览",
+ "submitFrigatePlus": {
+ "title": "提交此帧到 Frigate+?",
+ "submit": "提交"
+ },
+ "livePlayerRequiredIOSVersion": "此直播流类型需要 iOS 17.1 或更高版本。",
+ "streamOffline": {
+ "title": "视频流离线",
+ "desc": "未在 {{cameraName}} 的 detect
流上接收到任何帧,请检查错误日志"
+ },
+ "cameraDisabled": "摄像机已禁用",
+ "stats": {
+ "streamType": "流类型:",
+ "streamType.short": "类型",
+ "bandwidth": "带宽:",
+ "bandwidth.short": "带宽",
+ "latency": "延迟:",
+ "latency.short": "延迟",
+ "latency.value": "{{secounds}} 秒",
+ "latency.short.value": "{{secounds}} 秒",
+ "totalFrames": "总帧数:",
+ "droppedFrames": "丢帧数:",
+ "droppedFrames.short": "丢帧",
+ "droppedFrames.short.value": "{{droppedFrames}} 帧",
+ "decodedFrames": "解码帧数:",
+ "droppedFrameRate": "丢帧率:"
+ },
+ "toast": {
+ "success": {
+ "submittedFrigatePlus": "已成功提交帧到 Frigate+"
+ },
+ "error": {
+ "submitFrigatePlusFailed": "提交帧到 Frigate+ 失败"
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/zh-CN/objects.json b/web/public/locales/zh-CN/objects.json
new file mode 100644
index 000000000..6c0fe7fbd
--- /dev/null
+++ b/web/public/locales/zh-CN/objects.json
@@ -0,0 +1,104 @@
+{
+ "person": "人",
+ "bicycle": "自行车",
+ "car": "汽车",
+ "motorcycle": "摩托车",
+ "airplane": "飞机",
+ "bus": "公交车",
+ "train": "火车",
+ "boat": "船",
+ "traffic_light": "交通灯",
+ "fire_hydrant": "消防栓",
+ "street_sign": "路标",
+ "stop_sign": "停车标志",
+ "parking_meter": "停车计时器",
+ "bench": "长椅",
+ "bird": "鸟",
+ "cat": "猫",
+ "dog": "狗",
+ "horse": "马",
+ "sheep": "羊",
+ "cow": "牛",
+ "elephant": "大象",
+ "bear": "熊",
+ "zebra": "斑马",
+ "giraffe": "长颈鹿",
+ "hat": "帽子",
+ "backpack": "背包",
+ "umbrella": "雨伞",
+ "shoe": "鞋子",
+ "eye_glasses": "眼镜",
+ "handbag": "手提包",
+ "tie": "领带",
+ "suitcase": "手提箱",
+ "frisbee": "飞盘",
+ "skis": "滑雪板",
+ "snowboard": "滑雪板",
+ "sports_ball": "运动球",
+ "kite": "风筝",
+ "baseball_bat": "棒球棒",
+ "baseball_glove": "棒球手套",
+ "skateboard": "滑板",
+ "surfboard": "冲浪板",
+ "tennis_racket": "网球拍",
+ "bottle": "瓶子",
+ "plate": "盘子",
+ "wine_glass": "酒杯",
+ "cup": "杯子",
+ "fork": "叉子",
+ "knife": "刀",
+ "spoon": "勺子",
+ "bowl": "碗",
+ "banana": "香蕉",
+ "apple": "苹果",
+ "sandwich": "三明治",
+ "orange": "橙子",
+ "broccoli": "西兰花",
+ "carrot": "胡萝卜",
+ "hot_dog": "热狗",
+ "pizza": "披萨",
+ "donut": "甜甜圈",
+ "cake": "蛋糕",
+ "chair": "椅子",
+ "couch": "沙发",
+ "potted_plant": "盆栽植物",
+ "bed": "床",
+ "mirror": "镜子",
+ "dining_table": "餐桌",
+ "window": "窗户",
+ "desk": "桌子",
+ "toilet": "厕所",
+ "door": "门",
+ "tv": "电视",
+ "laptop": "笔记本电脑",
+ "mouse": "鼠标",
+ "remote": "遥控器",
+ "keyboard": "键盘",
+ "cell_phone": "手机",
+ "microwave": "微波炉",
+ "oven": "烤箱",
+ "toaster": "烤面包机",
+ "sink": "水槽",
+ "refrigerator": "冰箱",
+ "blender": "搅拌机",
+ "book": "书",
+ "clock": "时钟",
+ "vase": "花瓶",
+ "scissors": "剪刀",
+ "teddy_bear": "泰迪熊",
+ "hair_dryer": "吹风机",
+ "toothbrush": "牙刷",
+ "hair_brush": "发刷",
+ "vehicle": "车辆",
+ "squirrel": "松鼠",
+ "deer": "鹿",
+ "animal": "动物",
+ "bark": "树皮",
+ "fox": "狐狸",
+ "goat": "山羊",
+ "rabbit": "兔子",
+ "raccoon": "浣熊",
+ "robot_lawnmower": "自动割草机",
+ "waste_bin": "垃圾桶",
+ "on_demand": "手动"
+}
diff --git a/web/public/locales/zh-CN/views/configEditor.json b/web/public/locales/zh-CN/views/configEditor.json
new file mode 100644
index 000000000..9b37414a4
--- /dev/null
+++ b/web/public/locales/zh-CN/views/configEditor.json
@@ -0,0 +1,15 @@
+{
+ "documentTitle": "配置编辑器 - Frigate",
+ "configEditor": "配置编辑器",
+ "copyConfig": "复制配置",
+ "saveAndRestart": "保存并重启",
+ "saveOnly": "只保存",
+ "toast": {
+ "success": {
+ "copyToClipboard": "配置已复制到剪贴板。"
+ },
+ "error": {
+ "savingError": "保存配置时出错"
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/zh-CN/views/events.json b/web/public/locales/zh-CN/views/events.json
new file mode 100644
index 000000000..cc12c9838
--- /dev/null
+++ b/web/public/locales/zh-CN/views/events.json
@@ -0,0 +1,35 @@
+{
+ "alerts": "警告",
+ "detections": "检测",
+ "motion": {
+ "label": "运动",
+ "only": "仅运动画面"
+ },
+ "allCameras": "所有摄像头",
+ "empty": {
+ "alert": "还没有“警告”类回放",
+ "detection": "还没有“探测”类回放",
+ "motion": "还没有运动类数据"
+ },
+ "timeline": "时间线",
+ "timeline.aria": "选择时间线",
+ "events": {
+ "label": "事件",
+ "aria": "选择事件",
+ "noFoundForTimePeriod": "未找到该时间段的事件。"
+ },
+ "documentTitle": "预览 - Frigate",
+ "recordings": {
+ "documentTitle": "回放 - Frigate"
+ },
+ "calendarFilter": {
+ "last24Hours": "过去24小时"
+ },
+ "markAsReviewed": "标记为已审核",
+ "markTheseItemsAsReviewed": "将这些项目标记为已审核",
+ "newReviewItems": {
+ "label": "查看新的审核项目",
+ "button": "新的待审核项目"
+ },
+ "camera": "摄像头"
+}
diff --git a/web/public/locales/zh-CN/views/explore.json b/web/public/locales/zh-CN/views/explore.json
new file mode 100644
index 000000000..557fe2cea
--- /dev/null
+++ b/web/public/locales/zh-CN/views/explore.json
@@ -0,0 +1,175 @@
+{
+ "documentTitle": "探索 - Frigate",
+ "generativeAI": "生成式 AI",
+ "exploreIsUnavailable": {
+ "title": "探索功能不可用",
+ "embeddingsReindexing": {
+ "context": "跟踪对象嵌入重新索引完成后,可以使用探索功能。",
+ "startingUp": "启动中...",
+ "estimatedTime": "预计剩余时间:",
+ "finishingShortly": "即将完成",
+ "step": {
+ "thumbnailsEmbedded": "缩略图嵌入:",
+ "descriptionsEmbedded": "描述嵌入:",
+ "trackedObjectsProcessed": "跟踪对象已处理:"
+ }
+ },
+ "downloadingModels": {
+ "context": "Frigate正在下载支持语义搜索功能所需的嵌入模型。根据网络连接速度,这可能需要几分钟。",
+ "setup": {
+ "visionModel": "视觉模型",
+ "visionModelFeatureExtractor": "视觉模型特征提取器",
+ "textModel": "文本模型",
+ "textTokenizer": "文本分词器"
+ },
+ "tips": {
+ "context": "模型下载完成后,您可能需要重新索引跟踪对象的嵌入。",
+ "documentation": "阅读文档(英文)"
+ },
+ "error": "发生错误。请检查Frigate日志。"
+ }
+ },
+ "trackedObjectDetails": "探测对象详情",
+ "type": {
+ "details": "详情",
+ "snapshot": "快照",
+ "video": "视频",
+ "object_lifecycle": "对象生命周期"
+ },
+ "objectLifecycle": {
+ "title": "对象生命周期",
+ "noImageFound": "未找到此时间戳的图像。",
+ "createObjectMask": "创建对象遮罩",
+ "adjustAnnotationSettings": "调整标注设置",
+ "scrollViewTips": "滚动查看此对象生命周期的重要时刻。",
+ "autoTrackingTips": "自动跟踪摄像头的边界框位置可能不准确。",
+ "lifecycleItemDesc": {
+ "visible": "检测到 {{label}}",
+ "entered_zone": "{{label}} 进入 {{zones}}",
+ "active": "{{label}} 变为活动状态",
+ "stationary": "{{label}} 变为静止状态",
+ "attribute": {
+ "faceOrLicense_plate": "检测到 {{label}} 的 {{attribute}}",
+ "other": "{{label}} 识别为 {{attribute}}"
+ },
+ "gone": "{{label}} 离开",
+ "heard": "听到 {{label}}",
+ "external": "检测到 {{label}}"
+ },
+ "annotationSettings": {
+ "title": "标注设置",
+ "showAllZones": "显示所有区域",
+ "showAllZones.desc": "在对象进入区域的帧上始终显示区域。",
+ "offset": {
+ "label": "标注偏移",
+ "desc": "这些数据来自摄像头的检测源,但是叠加在录制源的图像上。这两个流不太可能完全同步。因此,边界框和录像不会完全对齐。但是,可以使用 annotation_offset
字段来调整这个问题。",
+ "documentation": "阅读文档(英文) ",
+ "millisecondsToOffset": "检测标注的偏移毫秒数。默认值:0 ",
+ "tips": "提示:假设有一个人从左向右走的事件片段。如果事件时间线上的边界框始终在人的左侧,则应该减小该值。同样,如果一个人从左向右走,而边界框始终在人的前面,则应该增加该值。"
+ }
+ },
+ "carousel": {
+ "previous": "上一张",
+ "next": "下一张"
+ }
+ },
+ "details": {
+ "item": {
+ "title": "回放项目详情",
+ "desc": "回放项目详情",
+ "button": {
+ "share": "分享该回放",
+ "viewInExplore": "在探测中查看"
+ },
+ "tips": {
+ "mismatch_one": "检测到 {{count}} 个不可用的对象,并已包含在此审核项中。这些对象可能未达到警告或检测标准,或者已被清理/删除。",
+ "mismatch_other": "检测到 {{count}} 个不可用的对象,并已包含在此审核项中。这些对象可能未达到警告或检测标准,或者已被清理/删除。",
+ "hasMissingObjects": "如果希望 Frigate 保存以下标签的跟踪对象,请调整您的配置:{{objects}} "
+ },
+ "toast": {
+ "success": {
+ "regenerate": "已向 {{provider}} 请求新的描述。根据提供商的速度,生成新描述可能需要一些时间。",
+ "updatedSublabel": "成功更新子标签。"
+ },
+ "error": {
+ "regenerate": "调用 {{provider}} 生成新描述失败:{{errorMessage}}",
+ "updatedSublabelFailed": "更新子标签失败:{{errorMessage}}"
+ }
+ }
+ },
+ "label": "标签",
+ "editSubLable": "编辑子标签",
+ "editSubLable.desc": "为 {{label}} 输入新的子标签",
+ "editSubLable.desc.noLabel": "为此跟踪对象输入新的子标签",
+ "topScore": "最高得分",
+ "topScore.info": "最高分是跟踪对象的最高中位数得分,因此可能与搜索结果缩略图上显示的得分不同。",
+ "estimatedSpeed": "预计速度",
+ "objects": "对象",
+ "camera": "摄像头",
+ "zones": "区域",
+ "timestamp": "时间",
+ "button": {
+ "findSimilar": "查找相似项"
+ },
+ "description": {
+ "label": "描述",
+ "placeholder": "跟踪对象的描述",
+ "aiTips": "在跟踪对象的生命周期结束之前,Frigate 不会向您的生成式 AI 提供商请求描述。"
+ },
+ "button.regenerate": "重新生成",
+ "button.regenerate.label": "重新生成跟踪对象描述",
+ "expandRegenerationMenu": "展开重新生成菜单",
+ "regenerateFromSnapshot": "从快照重新生成",
+ "regenerateFromThumbnails": "从缩略图重新生成",
+ "tips": {
+ "descriptionSaved": "已保存描述",
+ "saveDescriptionFailed": "更新描述失败:{{errorMessage}}"
+ }
+ },
+ "itemMenu": {
+ "downloadVideo": {
+ "label": "下载视频",
+ "aria": "下载视频"
+ },
+ "downloadSnapshot": {
+ "label": "下载快照",
+ "aria": "下载快照"
+ },
+ "viewObjectLifecycle": {
+ "label": "查看对象生命周期",
+ "aria": "显示对象的生命周期"
+ },
+ "findSimilar": {
+ "label": "查找相似项",
+ "aria": "查看相似的对象"
+ },
+ "submitToPlus": {
+ "label": "提交至 Frigate+",
+ "aria": "提交至 Frigate Plus"
+ },
+ "viewInHistory": {
+ "label": "在历史记录中查看",
+ "aria": "在历史记录中查看"
+ },
+ "deleteTrackedObject": {
+ "label": "删除此跟踪对象"
+ }
+ },
+ "dialog": {
+ "confirmDelete": {
+ "title": "确认删除",
+ "desc": "删除此跟踪对象将移除快照、所有已保存的嵌入数据以及任何关联的对象生命周期条目。但在历史视图中的录制视频不会 被删除。 你确定要继续删除吗?"
+ }
+ },
+ "noTrackedObjects": "找不到探测的对象",
+ "fetchingTrackedObjectsFailed": "获取跟踪对象失败:{{errorMessage}}",
+ "trackedObjectsCount": "{{count}} 个跟踪对象",
+ "searchResult": {
+ "deleteTrackedObject": {
+ "toast": {
+ "success": "跟踪对象删除成功。",
+ "error": "删除跟踪对象失败:{{errorMessage}}"
+ }
+ }
+ }
+}
diff --git a/web/public/locales/zh-CN/views/exports.json b/web/public/locales/zh-CN/views/exports.json
new file mode 100644
index 000000000..fe3f849ac
--- /dev/null
+++ b/web/public/locales/zh-CN/views/exports.json
@@ -0,0 +1,17 @@
+{
+ "documentTitle": "导出 - Frigate",
+ "search": "搜索",
+ "noExports": "没有找到导出的项目",
+ "deleteExport": "删除导出的项目",
+ "deleteExport.desc": "你确定要删除 {{exportName}} 吗?",
+ "editExport": {
+ "title": "重命名导出",
+ "desc": "为此导出项目输入新名称。",
+ "saveExport": "保存导出"
+ },
+ "toast": {
+ "error": {
+ "renameExportFailed": "重命名导出失败:{{errorMessage}}"
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/public/locales/zh-CN/views/faceLibrary.json b/web/public/locales/zh-CN/views/faceLibrary.json
new file mode 100644
index 000000000..49273155e
--- /dev/null
+++ b/web/public/locales/zh-CN/views/faceLibrary.json
@@ -0,0 +1,41 @@
+{
+ "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": "人脸分数更新成功。"
+ },
+ "error": {
+ "uploadingImageFailed": "图片上传失败:{{errorMessage}}",
+ "addFaceLibraryFailed": "设置人脸名称失败:{{errorMessage}}",
+ "deleteFaceFailed": "删除失败:{{errorMessage}}",
+ "trainFailed": "训练失败:{{errorMessage}}",
+ "updateFaceScoreFailed": "更新人脸分数失败:{{errorMessage}}"
+ }
+ }
+}
diff --git a/web/public/locales/zh-CN/views/live.json b/web/public/locales/zh-CN/views/live.json
new file mode 100644
index 000000000..95b0797b9
--- /dev/null
+++ b/web/public/locales/zh-CN/views/live.json
@@ -0,0 +1,154 @@
+{
+ "documentTitle": "实时监控 - Frigate",
+ "documentTitle.withCamera": "{{camera}} - 实时监控 - Frigate",
+ "lowBandwidthMode": "低带宽模式",
+ "twoWayTalk": {
+ "enable": "开启双向对话",
+ "disable": "关闭双向对话"
+ },
+ "cameraAudio": {
+ "enable": "开启摄像头音频",
+ "disable": "关闭摄像头音频"
+ },
+ "ptz": {
+ "move": {
+ "clickMove": {
+ "label": "点击画面以使摄像头居中",
+ "enable": "启用点击移动",
+ "disable": "禁用点击移动"
+ },
+ "left": {
+ "label": "PTZ摄像头向左移动"
+ },
+ "up": {
+ "label": "PTZ摄像头向上移动"
+ },
+ "down": {
+ "label": "PTZ摄像头向下移动"
+ },
+ "right": {
+ "label": "PTZ摄像头向右移动"
+ }
+ },
+ "zoom": {
+ "in": {
+ "label": "PTZ摄像头放大"
+ },
+ "out": {
+ "label": "PTZ摄像头缩小"
+ }
+ },
+ "frame": {
+ "center": {
+ "label": "点击将PTZ摄像头画面居中"
+ }
+ },
+ "presets": "PTZ摄像头预设"
+ },
+ "camera": {
+ "enable": "开启摄像头",
+ "disable": "关闭摄像头"
+ },
+ "muteCameras": {
+ "enable": "屏蔽所有摄像头",
+ "disable": "取消屏蔽所有摄像头"
+ },
+ "detect": {
+ "enable": "启用检测",
+ "disable": "关闭检测"
+ },
+ "recording": {
+ "enable": "启用录制",
+ "disable": "关闭录制"
+ },
+ "snapshots": {
+ "enable": "启用快照",
+ "disable": "关闭快照"
+ },
+ "audioDetect": {
+ "enable": "启用音频检测",
+ "disable": "关闭音频检测"
+ },
+ "autotracking": {
+ "enable": "启用自动追踪",
+ "disable": "关闭自动追踪"
+ },
+ "streamStats": {
+ "enable": "显示视频流统计信息",
+ "disable": "隐藏视频流统计信息"
+ },
+ "manualRecording": {
+ "title": "按需录制",
+ "tips": "根据此摄像机的录制保留设置,手动启动事件。",
+ "playInBackground": {
+ "label": "后台播放",
+ "desc": "启用此选项可在播放器隐藏时继续视频流播放。"
+ },
+ "showStats": {
+ "label": "显示统计信息",
+ "desc": "启用此选项可在摄像机画面上叠加显示视频流统计信息。"
+ },
+ "debugView": "调试视图",
+ "start": "开始手动按需录制",
+ "started": "已启用手动按需录制",
+ "failedToStart": "启动手动录制失败",
+ "recordDisabledTips": "由于此摄像头的配置中禁用了录制或对其进行了限制,将只会保存快照。",
+ "end": "停止手动按需录制",
+ "ended": "已完成手动按需录制",
+ "failedToEnd": "停止手动录制失败"
+ },
+ "streamingSettings": "视频流设置",
+ "notifications": "通知",
+ "audio": "音频",
+ "suspend": {
+ "forTime": "暂停时长:"
+ },
+ "stream": {
+ "title": "视频流",
+ "audio": {
+ "tips": "音频必须从摄像机输出并在 go2rtc 中配置为此视频流使用。",
+ "tips.documentation": "阅读文档 ",
+ "available": "此视频流支持音频",
+ "unavailable": "此视频流不支持音频"
+ },
+ "twoWayTalk": {
+ "tips": "您的设备必须支持此功能,并且必须配置 WebRTC 以支持双向对讲。",
+ "tips.documentation": "阅读文档 ",
+ "available": "此视频流支持双向对讲",
+ "unavailable": "此视频流不支持双向对讲"
+ },
+ "lowBandwidth": {
+ "tips": "由于缓冲或视频流错误,实时视图处于低带宽模式。",
+ "resetStream": "重置视频流"
+ },
+ "playInBackground": {
+ "label": "后台播放",
+ "tips": "启用此选项可在播放器隐藏时继续视频流播放。"
+ }
+ },
+ "cameraSettings": {
+ "title": "{{camera}} 设置",
+ "cameraEnabled": "摄像机已启用",
+ "objectDetection": "对象检测",
+ "recording": "录制",
+ "snapshots": "快照",
+ "audioDetection": "音频检测",
+ "autotracking": "自动跟踪"
+ },
+ "history": {
+ "label": "显示历史录像"
+ },
+ "effectiveRetainMode": {
+ "modes": {
+ "all": "全部",
+ "motion": "运动",
+ "active_objects": "活动对象"
+ },
+ "notAllTips": "您的 {{source}} 录制保留配置设置为 mode: {{effectiveRetainMode}}
,因此此按需录制将仅保留包含 {{effectiveRetainModeName}} 的片段。"
+ },
+ "editLayout": {
+ "label": "编辑布局",
+ "group.label": "编辑摄像机分组",
+ "exitEdit": "退出编辑"
+}
+}
diff --git a/web/public/locales/zh-CN/views/recording.json b/web/public/locales/zh-CN/views/recording.json
new file mode 100644
index 000000000..37cab2514
--- /dev/null
+++ b/web/public/locales/zh-CN/views/recording.json
@@ -0,0 +1,12 @@
+{
+ "export": "导出",
+ "calendar": "日历",
+ "filter": "筛选",
+ "filters": "筛选条件",
+ "toast": {
+ "error": {
+ "noValidTimeSelected": "未选择有效的时间范围",
+ "endTimeMustAfterStartTime": "结束时间必须晚于开始时间"
+ }
+ }
+}
diff --git a/web/public/locales/zh-CN/views/search.json b/web/public/locales/zh-CN/views/search.json
new file mode 100644
index 000000000..b33055931
--- /dev/null
+++ b/web/public/locales/zh-CN/views/search.json
@@ -0,0 +1,65 @@
+{
+ "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": "包含快照"
+ },
+ "searchType": {
+ "thumbnail": "缩略图",
+ "description": "描述"
+ },
+ "toast": {
+ "error": {
+ "beforeDateBeLaterAfter": "“之前”日期必须晚于“之后”日期。",
+ "afterDatebeEarlierBefore": "“之后”日期必须早于“之前”日期。",
+ "minScoreMustBeLessOrEqualMaxScore": "最小分值 必须小于或等于 最大分值。",
+ "maxScoreMustBeGreaterOrEqualMinScore": "最大分值 必须大于或等于 最小分值",
+ "minSpeedMustBeLessOrEqualMaxSpeed": "最低速度 必须小于或等于 最高速度",
+ "maxSpeedMustBeGreaterOrEqualMinSpeed": "最高速度 必须大于或等于 最低速度"
+ }
+ },
+ "tips": {
+ "title": "如何使用文本筛选器(英文)",
+ "desc": "筛选器可帮助您缩小搜索范围。注意,目前还暂不支持中文搜索。以下是在输入字段中使用筛选器的方法:",
+ "desc.step": "输入筛选器名称后跟一个冒号(例如:“cameras:”)。 从建议中选择一个值或输入您自己的值。 使用多个筛选器时,可以在它们之间用空格分隔。 日期筛选器(before: 和 after:)使用 {{DateFormat}} 格式。 时间范围筛选器使用 {{exampleTime}} 格式。 点击筛选器旁边的“x”即可移除筛选条件。 ",
+ "desc.example": "示例:cameras:front_door label:person before:01012024 time_range:3:00PM-4:00PM
"
+ },
+ "header": {
+ "currentFilterType": "筛选值",
+ "noFilters": "筛选条件",
+ "activeFilters": "激活的筛选项"
+ }
+ },
+ "similaritySearch": {
+ "title": "相似搜索",
+ "active": "相似搜索已激活",
+ "clear": "清除相似搜索"
+ },
+ "placeholder": {
+ "search": "搜索..."
+ }
+}
diff --git a/web/public/locales/zh-CN/views/settings.json b/web/public/locales/zh-CN/views/settings.json
new file mode 100644
index 000000000..664796122
--- /dev/null
+++ b/web/public/locales/zh-CN/views/settings.json
@@ -0,0 +1,433 @@
+{
+ "documentTitle": {
+ "default": "设置 - Frigate",
+ "authentication": "身份验证设置 - Frigate",
+ "camera": "摄像头设置 - Frigate",
+ "classification": "分类设置 - Frigate",
+ "masksAndZones": "遮罩和区域编辑器 - Frigate",
+ "motionTuner": "运动调整器 - Frigate",
+ "object": "对象设置 - Frigate",
+ "general": "常规设置 - Frigate"
+ },
+ "dialog": {
+ "unsavedChanges": {
+ "title": "你有未保存的更改。",
+ "desc": "是否要在继续之前保存更改?"
+ }
+ },
+ "menu": {
+ "uiSettings": "界面设置",
+ "classificationSettings": "分类设置",
+ "cameraSettings": "摄像头设置",
+ "masksAndZones": "遮罩/ 区域",
+ "motionTuner": "运动调整器",
+ "debug": "调试",
+ "users": "用户",
+ "notifications": "通知"
+ },
+ "general": {
+ "title": "常规设置",
+ "liveDashboard": {
+ "title": "实时监控面板",
+ "automaticLiveView": {
+ "label": "自动实时预览",
+ "desc": "检测到画面活动时将自动切换至该摄像头实时画面。禁用此选项会导致实时监控页面的摄像头图像每分钟只更新一次。"
+ },
+ "playAlertVideos": {
+ "label": "播放警告视频",
+ "desc": "默认情况下,实时监控页面上的最新警告会以一小段循环的形式进行播放。禁用此选项将仅显示浏览器本地缓存的静态图片。"
+ }
+ },
+ "storedLayouts": {
+ "title": "存储监控面板布局",
+ "desc": "可以在监控面板调整或拖动摄像头的布局。这些设置将保存在浏览器的本地存储中。",
+ "clearAll": "清除所有布局"
+ },
+ "cameraGroupStreaming": {
+ "title": "摄像头组视频流设置",
+ "desc": "每个摄像头组的视频流设置将保存在浏览器的本地存储中。",
+ "clearAll": "清除所有视频流设置"
+ },
+ "recordingsViewer": {
+ "title": "回放查看",
+ "defaultPlaybackRate": {
+ "label": "默认播放速率",
+ "desc": "调整播放录像时默认的速率。"
+ }
+ },
+ "calendar": {
+ "title": "日历",
+ "firstWeekday": {
+ "label": "每周第一天",
+ "desc": "设置每周第一天是星期几。",
+ "sunday": "星期天",
+ "monday": "星期一"
+ }
+ },
+ "toast": {
+ "success": {
+ "clearStoredLayout": "已清除 {{cameraName}} 的存储布局",
+ "clearStreamingSettings": "已清除所有摄像头组的视频流设置。"
+ },
+ "error": {
+ "clearStoredLayoutFailed": "清除存储布局失败:{{errorMessage}}",
+ "clearStreamingSettingsFailed": "清除视频流设置失败:{{errorMessage}}"
+ }
+ }
+ },
+ "classification": {
+ "title": "分类设置",
+ "semanticSearch": {
+ "title": "语义搜索",
+ "desc": "Frigate的语义搜索能够让你使用自然语言根据图像本身、自定义的文本描述或自动生成的描述来搜索视频。",
+ "readTheDocumentation": "阅读文档(英文)",
+ "reindexOnStartup": {
+ "label": "启动时重新索引",
+ "desc": "每次启动将重新索引并重新处理所有缩略图和描述。关闭该设置后不要忘记重启! "
+ },
+ "modelSize": {
+ "label": "模型大小",
+ "desc": "用于语义搜索的语言模型大小",
+ "small": "小",
+ "large": "大",
+ "small.desc": "使用 小 模型。该模型将使用较少的内存,在CPU上也能较快的运行。质量较好。",
+ "large.desc": "使用 大 模型。该模型采用了完整的Jina模型,并在适用的情况下使用GPU。"
+ }
+ },
+ "faceRecognition": {
+ "title": "人脸识别",
+ "desc": "人脸识别功能允许为人物分配名称,当识别到他们的面孔时,Frigate 会将人物的名字作为子标签进行分配。这些信息会显示在界面、过滤器以及通知中。",
+ "readTheDocumentation": "阅读文档(英文)"
+ },
+ "licensePlateRecognition": {
+ "title": "车牌识别",
+ "desc": "Frigate 可以识别车辆的车牌,并自动将检测到的字符添加到 recognized_license_plate 字段中,或将已知名称作为子标签添加到汽车类型的对象中。常见的使用场景可能是读取驶入车道的汽车车牌或经过街道的汽车车牌。",
+ "readTheDocumentation": "阅读文档(英文)"
+ },
+ "toast": {
+ "success": "分类设置已保存。",
+ "error": "保存配置更改失败:{{errorMessage}}"
+ }
+ },
+ "camera": {
+ "title": "摄像头设置",
+ "streams": {
+ "title": "视频流",
+ "desc": "禁用摄像头将完全停止 Frigate 对该摄像头视频流的处理。检测、录制和调试功能都将不可用。注意:该选项不会禁用 go2rtc 转播。 "
+ },
+ "review": {
+ "title": "预览",
+ "desc": "启用/禁用摄像头的警报和检测。禁用后,不会生成新的预览项。",
+ "alerts": "警告 ",
+ "detections": "检测 "
+ },
+ "reviewClassification": {
+ "title": "预览分级",
+ "desc": "Frigate 将回放项目分为“警告”和“检测”。默认情况下,所有的 人 、汽车 的对象都视为警告。你可以通过修改配置文件配置区域来细分。",
+ "readTheDocumentation": "阅读文档(英文)",
+ "noDefinedZones": "该摄像头没有设置区域。",
+ "objectAlertsTips": "所有的 {{alertsLabels}} 对象在 {{cameraName}} 都将显示为警告。",
+ "zoneObjectAlertsTips": "所有的 {{alertsLabels}} 对象在 {{cameraName}} 的 {{zone}} 区域都将显示为警告。",
+ "objectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。",
+ "zoneObjectDetectionsTips": "所有未在 {{cameraName}} 上归类为 {{detectionsLabels}} 的对象在 {{zone}} 区域都将显示为检测。",
+ "zoneObjectDetectionsTips.notSelectDetections": "所有在 {{cameraName}} 的 {{zone}} 上检测到的未归类为警告的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。",
+ "zoneObjectDetectionsTips.regardlessOfZoneObjectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。",
+ "selectAlertsZones": "选择要显示为警告的区域",
+ "selectDetectionsZones": "选择检测区域",
+ "limitDetections": "限制仅在特定区域内进行检测",
+ "toast": {
+ "success": "预览分级配置已保存。请重启 Frigate 以应用更改。"
+ }
+ }
+ },
+ "masksAndZones": {
+ "filter": {
+ "all": "所有遮罩和区域"
+ },
+ "toast": {
+ "success": {
+ "copyCoordinates": "已复制 {{polyName}} 的坐标到剪贴板。"
+ },
+ "error": {
+ "copyCoordinatesFailed": "无法复制坐标到剪贴板。"
+ }
+ },
+ "form": {
+ "zoneName": {
+ "error": {
+ "mustBeAtLeastTwoCharacters": "区域名称必须至少包含 2 个字符。",
+ "mustNotBeSameWithCamera": "区域名称不能与摄像头名称相同。",
+ "alreadyExists": "该摄像头已有相同的区域名称。",
+ "mustNotContainPeriod": "区域名称不能包含句点。",
+ "hasIllegalCharacter": "区域名称包含非法字符。"
+ }
+ },
+ "distance.error": "距离必须大于或等于 0.1。",
+ "distance.error.mustBeFilled": "所有距离字段必须填写才能使用速度估算。",
+ "inertia.error.mustBeAboveZero": "惯性必须大于 0。",
+ "loiteringTime.error.mustBeGreaterOrEqualZero": "徘徊时间必须大于或等于 0。",
+ "polygonDrawing": {
+ "removeLastPoint": "删除最后一个点",
+ "reset.label": "清除所有点",
+ "snapPoints": {
+ "true": "启用点对齐",
+ "false": "禁用点对齐"
+ },
+ "delete": {
+ "title": "确认删除",
+ "desc": "你确定要删除{{type}} {{name}} 吗?",
+ "success": "{{name}} 已被删除。"
+ },
+ "error": {
+ "mustBeFinished": "多边形绘制必须完成闭合后才能保存。"
+ }
+ }
+ },
+ "zones": {
+ "label": "区域",
+ "documentTitle": "编辑区域 - Frigate",
+ "desc": "该功能允许你定义特定区域,以便你可以确定特定对象是否在该区域内。",
+ "desc.documentation": "文档(英文)",
+ "add": "添加区域",
+ "edit": "编辑区域",
+ "point_one": "{{count}} 点",
+ "point_other": "{{count}} 点",
+ "clickDrawPolygon": "在图像上点击添加点绘制多边形区域。",
+ "name": "区域名称",
+ "name.inputPlaceHolder": "请输入名称",
+ "name.tips": "名称至少包含两个字符,且不能和摄像头或其他区域同名。 当前仅支持英文与数字组合",
+ "inertia": "惯性",
+ "inertia.desc": "识别指定对象前该对象必须在这个区域内出现了多少帧。默认值:3 ",
+ "loiteringTime": "停留时间",
+ "loiteringTime.desc": "设置对象必须在区域中活动的最小时间(单位为秒)。默认值:0 ",
+ "objects": "对象",
+ "objects.desc": "将在此区域应用的对象列表。",
+ "allObjects": "所有对象",
+ "speedEstimation": "速度估算",
+ "speedEstimation.desc": "启用此区域内物体的速度估算。该区域必须恰好包含 4 个点。",
+ "speedThreshold": "速度阈值 ({{unit}})",
+ "speedThreshold.desc": "指定物体在此区域内被视为有效的最低速度。",
+ "speedThreshold.toast.error.pointLengthError": "此区域的速度估算已禁用。启用速度估算的区域必须恰好包含 4 个点。",
+ "speedThreshold.toast.error.loiteringTimeError": "徘徊时间大于 0 的区域不应与速度估算一起使用。",
+ "toast.success": "区域 ({{zoneName}}) 已保存。请重启 Frigate 以应用更改。"
+ },
+ "motionMasks": {
+ "label": "运动遮罩",
+ "documentTitle": "编辑运动遮罩 - Frigate",
+ "desc": "运动遮罩用于防止触发不必要的运动类型。过度的设置遮罩将使对象更加难以被追踪",
+ "desc.documentation": "文档(英文)",
+ "add": "添加运动遮罩",
+ "edit": "编辑运动遮罩",
+ "context": "运动遮罩用于防止不需要的运动类型触发检测(例如:树枝、摄像头显示的时间等)。运动遮罩需要谨慎使用 ,过度的遮罩会导致追踪对象变得更加困难。",
+ "context.documentation": "阅读文档(英文)",
+ "point_one": "{{count}} 点",
+ "point_other": "{{count}} 点",
+ "clickDrawPolygon": "在图像上点击添加点绘制多边形区域。",
+ "polygonAreaTooLarge": "运动遮罩的大小达到了摄像头画面的{{polygonArea}}%。不建议设置太大的运动遮罩。",
+ "polygonAreaTooLarge.tips": "运动遮罩不会阻止检测到对象,你应该使用区域来限制检测对象。",
+ "polygonAreaTooLarge.documentation": "阅读文档(英文)",
+ "toast.success": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。",
+ "toast.success.noName": "运动遮罩已保存。请重启 Frigate 以应用更改。"
+ },
+ "objectMasks": {
+ "label": "对象遮罩",
+ "documentTitle": "编辑对象遮罩 - Frigate",
+ "desc": "对象过滤器用于防止特定位置的指定对象被误报。",
+ "documentation": "文档(英文)",
+ "add": "添加对象遮罩",
+ "edit": "编辑对象遮罩",
+ "context": "对象过滤器用于防止特定位置的指定对象被误报。",
+ "point_one": "{{count}} 点",
+ "point_other": "{{count}} 点",
+ "clickDrawPolygon": "在图像上点击添加点绘制多边形区域。",
+ "objects": "对象",
+ "objects.desc": "将应用于此对象遮罩的对象列表。",
+ "objects.allObjectTypes": "所有对象类型",
+ "toast.success": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。",
+ "toast.success.noName": "对象遮罩已保存。请重启 Frigate 以应用更改。"
+ }
+ },
+ "motionDetectionTuner": {
+ "title": "运动检测调整器",
+ "desc": "Frigate 将使用运动检测作为首个步骤,以确认一帧画面中是否有对象需要使用对象检测。",
+ "desc.documentation": "阅读有关运动检测的文档(英文)",
+ "Threshold": "阈值",
+ "Threshold.desc": "阈值决定像素亮度高于多少时会被认为是运动。默认值:30 ",
+ "contourArea": "轮廓面积",
+ "contourArea.desc": "轮廓面积决定哪些变化的像素组符合运动条件。默认值:10 ",
+ "improveContrast": "提高对比度",
+ "improveContrast.desc": "提高较暗场景的对比度。默认值:开启",
+ "toast": {
+ "success": "运动设置已保存。"
+ }
+ },
+ "debug": {
+ "title": "调试",
+ "detectorDesc": "Frigate 将使用探测器({{detectors}})来检测摄像头视频流中的对象。",
+ "desc": "调试界面将实时显示被追踪的对象以及统计信息,对象列表将显示检测到的对象和延迟显示的概览。",
+ "debugging": "调试选项",
+ "objectList": "对象列表",
+ "noObjects": "没有对象",
+ "boundingBoxes": {
+ "title": "边界框",
+ "desc": "将在被追踪的对象周围显示边界框",
+ "colors": {
+ "label": "对象边界框颜色定义",
+ "info": "启用后,将会为每个对象标签分配不同的颜色 深蓝色细线代表该对象在当前时间点未被检测到 灰色细线代表检测到的物体静止不动 粗线表示该对象为自动跟踪的主体(在启动时) "
+ }
+ },
+ "timestamp": {
+ "title": "时间戳",
+ "desc": "在图像上显示时间戳"
+ },
+ "zones": {
+ "title": "区域",
+ "desc": "显示已定义的区域图层"
+ },
+ "mask": {
+ "title": "运动遮罩",
+ "desc": "显示运动遮罩图层"
+ },
+ "motion": {
+ "title": "运动区域框",
+ "desc": "在检测到运动的区域显示区域框",
+ "tips": "运动区域框
将在当前检测到运动的区域内显示红色区域框。
"
+ },
+ "regions": {
+ "title": "范围",
+ "desc": "显示发送到运动检测器感兴趣范围的框。",
+ "tips": "范围框
将在帧中发送到目标检测器的感兴趣范围上叠加绿色框。
"
+ },
+ "objectShapeFilterDrawing": {
+ "title": "允许绘制“对象形状过滤器”",
+ "desc": "在图像上绘制矩形,以查看区域和比例详细信息。",
+ "tips": "启用此选项,能够在摄像头图像上绘制矩形,将显示其区域和比例。然后,您可以使用这些值在配置中设置对象形状过滤器参数。",
+ "document": "阅读文档(英文)",
+ "score": "分数",
+ "ratio": "比例",
+ "area": "区域"
+ }
+ },
+ "users": {
+ "title": "用户",
+ "management": "用户管理",
+ "management.desc": "管理此 Frigate 实例的用户账户。",
+ "addUser": "添加用户",
+ "updatePassword": "修改密码",
+ "toast": {
+ "success": {
+ "createUser": "用户 {{user}} 创建成功",
+ "deleteUser": "用户 {{user}} 删除成功",
+ "updatePassword": "已成功修改密码",
+ "roleUpdated": "已更新 {{user}} 的权限组"
+ },
+ "error": {
+ "setPasswordFailed": "保存密码出现错误:{{errorMessage}}",
+ "createUserFailed": "创建用户失败:{{errorMessage}}",
+ "deleteUserFailed": "删除用户失败:{{errorMessage}}",
+ "roleUpdateFailed": "更新权限组失败:{{errorMessage}}"
+ }
+ },
+ "table": {
+ "username": "用户名",
+ "actions": "操作",
+ "role": "权限组",
+ "noUsers": "未找到用户。",
+ "changeRole": "更改用户角色",
+ "password": "密码",
+ "deleteUser": "删除用户"
+ },
+ "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": "请再次输入新密码",
+ "usernameIsRequired": "用户名为必填项"
+ },
+ "createUser": {
+ "title": "创建新用户",
+ "desc": "创建一个新用户账户,并指定一个角色以控制访问 Frigate UI 的权限。",
+ "user": "用户",
+ "password": "密码",
+ "usernameOnlyInclude": "用户名只能包含字母、数字和 _"
+ },
+ "deleteUser": {
+ "title": "删除该用户",
+ "desc": "此操作无法撤销。这将永久删除用户账户并移除所有相关数据。",
+ "warn": "你确定要删除 {{username}} 吗?"
+ },
+ "passwordSetting": {
+ "updatePassword": "更新 {{username}} 的密码",
+ "setPassword": "设置密码",
+ "desc": "创建一个强密码来保护此账户。"
+ },
+ "changeRole": {
+ "title": "更改用户权限组",
+ "desc": "更新 {{username}} 的权限",
+ "roleInfo": "请选择此用户的适当角色:
• 管理员 (Admin): 拥有所有功能的完整访问权限。 • 查看者 (Viewer): 仅限访问实时监控、回放、探测和导出功能。 "
+ }
+ }
+ },
+ "notification": {
+ "title": "通知",
+ "notificationSettings": {
+ "title": "通知设置",
+ "desc": "Frigate 在浏览器中运行或作为 PWA 安装时,可以原生向您的设备发送推送通知。",
+ "documentation": "阅读文档(英文)"
+ },
+ "globalSettings": {
+ "title": "全局设置",
+ "desc": "临时暂停所有已注册设备上特定摄像头的通知。"
+ },
+ "notificationUnavailable": {
+ "title": "通知功能不可用",
+ "desc": "网页推送通知需要安全连接(https://...
)。这是浏览器的限制。请通过安全方式访问 Frigate 以使用通知功能。",
+ "documentation": "阅读文档(英文)"
+ },
+ "email": "电子邮箱",
+ "email.placeholder": "例如:example@email.com",
+ "email.desc": "需要输入有效的电子邮件,在推送服务出现问题时,将使用此电子邮件进行通知。",
+ "cameras": "摄像头",
+ "cameras.noCameras": "没有可用的摄像头",
+ "cameras.desc": "选择要启用通知的摄像头。",
+ "deviceSpecific": "设备专用设置",
+ "registerDevice": "注册该设备",
+ "unregisterDevice": "取消注册该设备",
+ "sendTestNotification": "发送测试通知",
+ "active": "通知已启用",
+ "suspended": "通知已暂停 {{time}}",
+ "suspendTime": {
+ "5minutes": "暂停 5 分钟",
+ "10minutes": "暂停 10 分钟",
+ "30minutes": "暂停 30 分钟",
+ "1hour": "暂停 1 小时",
+ "12hours": "暂停 12 小时",
+ "24hours": "暂停 24 小时",
+ "untilRestart": "暂停直到重启"
+ },
+ "cancelSuspension": "取消暂停",
+ "toast": {
+ "success": {
+ "registered": "已成功注册通知。需要重启 Frigate 才能发送任何通知(包括测试通知)。",
+ "settingSaved": "通知设置已保存。"
+ },
+ "error": {
+ "registerFailed": "通知注册失败。"
+ }
+ }
+ }
+}
diff --git a/web/public/locales/zh-CN/views/system.json b/web/public/locales/zh-CN/views/system.json
new file mode 100644
index 000000000..548818ac2
--- /dev/null
+++ b/web/public/locales/zh-CN/views/system.json
@@ -0,0 +1,144 @@
+{
+ "documentTitle": {
+ "cameras": "摄像头统计 - Frigate",
+ "storage": "存储统计 - Frigate",
+ "general": "常规统计 - Frigate",
+ "features": "功能统计 - Frigate",
+ "logs": {
+ "frigate": "Frigate 日志 - Frigate",
+ "go2rtc": "Go2RTC 日志 - Frigate",
+ "nginx": "Nginx 日志 - Frigate"
+ }
+ },
+ "title": "系统",
+ "metrics": "系统指标",
+ "logs": {
+ "download": {
+ "label": "下载日志"
+ },
+ "copy": {
+ "label": "复制到剪贴板",
+ "success": "已复制日志到剪贴板",
+ "error": "无法复制日志到剪贴板"
+ },
+ "type": {
+ "label": "类型",
+ "timestamp": "时间戳",
+ "tag": "标签",
+ "message": "消息"
+ },
+ "tips": "日志正在从服务器流式传输",
+ "toast": {
+ "error": {
+ "fetchingLogsFailed": "获取日志出错:{{errorMessage}}",
+ "whileStreamingLogs": "流式传输日志时出错:{{errorMessage}}"
+ }
+ }
+ },
+ "general": {
+ "title": "常规",
+ "detector": {
+ "title": "探测器",
+ "inferenceSpeed": "探测器推理速度",
+ "cpuUsage": "探测器CPU使用率",
+ "memoryUsage": "探测器内存使用率"
+ },
+ "hardwareInfo": {
+ "title": "硬件信息",
+ "gpuUsage": "GPU使用率",
+ "gpuMemory": "GPU显存",
+ "gpuEncoder": "GPU编码",
+ "gpuDecoder": "GPU解码",
+ "gpuInfo": {
+ "vainfoOutput": {
+ "title": "Vainfo 输出",
+ "returnCode": "返回代码:{{code}}",
+ "processOutput": "进程输出:",
+ "processError": "进程错误:"
+ },
+ "nvidiaSMIOutput": {
+ "title": "Nvidia SMI 输出",
+ "name": "名称:{{name}}",
+ "driver": "驱动:{{driver}}",
+ "cudaComputerCapability": "CUDA计算能力:{{cuda_compute}}",
+ "vbios": "VBios信息:{{vbios}}"
+ },
+ "closeInfo.label": "关闭GPU信息",
+ "copyInfo.label": "复制GPU信息",
+ "toast": {
+ "success": "已复制GPU信息到剪贴板"
+ }
+ }
+ },
+ "otherProcesses": {
+ "title": "其他进程",
+ "processCpuUsage": "主进程CPU使用率",
+ "processMemoryUsage": "主进程内存使用率"
+ }
+ },
+ "storage": {
+ "title": "存储",
+ "overview": "概览",
+ "recordings": {
+ "title": "录制内容",
+ "tips": "该值表示 Frigate 数据库中录制内容所使用的总存储空间。Frigate 不会追踪磁盘上所有文件的存储使用情况。",
+ "earliestRecording": "最早的可用录制:"
+ },
+ "cameraStorage": {
+ "title": "摄像头存储",
+ "camera": "摄像头",
+ "unused": "未使用",
+ "unusedStorageInformation": "未使用存储信息",
+ "storageUsed": "存储使用",
+ "percentageOfTotalUsed": "总使用率",
+ "bandwidth": "带宽",
+ "unused.tips": "如果您的驱动器上存储了除 Frigate 录制内容之外的其他文件,该值可能无法准确反映 Frigate 可用的剩余空间。Frigate 不会追踪录制内容以外的存储使用情况。"
+ }
+ },
+ "cameras": {
+ "title": "摄像头",
+ "overview": "概览",
+ "info": {
+ "cameraProbeInfo": "{{camera}} 的摄像头信息",
+ "streamDataFromFFPROBE": "流数据信息通过ffprobe
获取。",
+ "fetching": "正在获取摄像头数据",
+ "stream": "视频流{{idx}}",
+ "video": "视频:",
+ "codec": "编解码器:",
+ "resolution": "分辨率:",
+ "fps": "帧率:",
+ "unknown": "未知",
+ "audio": "音频:",
+ "error": "错误:{{error}}",
+ "tips": {
+ "title": "摄像头信息"
+ }
+ },
+ "framesAndDetections": "帧数/检测次数",
+ "label": {
+ "camera": "摄像头",
+ "detect": "探测",
+ "skipped": "跳过",
+ "ffmpeg": "ffmpeg编码器",
+ "capture": "捕获"
+ },
+ "toast": {
+ "success": {
+ "copyToClipboard": "已复制探测数据到剪贴板。"
+ },
+ "error": {
+ "unableToProbeCamera": "无法探测摄像头:{{errorMessage}}"
+ }
+ }
+ },
+ "lastRefreshed": "最后刷新时间:",
+ "stats": {
+ "ffmpegHighCpuUsage": "{{camera}} 的 FFMPEG CPU 使用率较高({{ffmpegAvg}}%)",
+ "detectHighCpuUsage": "{{camera}} 的 探测 CPU 使用率较高({{detectAvg}}%)",
+ "healthy": "系统运行正常",
+ "reindexingEmbeddings": "正在重新索引嵌入(已完成 {{processed}}%)"
+ },
+ "features": {
+ "title": "功能"
+ }
+}
diff --git a/web/src/components/Statusbar.tsx b/web/src/components/Statusbar.tsx
index 1b20b26f6..2b03b750d 100644
--- a/web/src/components/Statusbar.tsx
+++ b/web/src/components/Statusbar.tsx
@@ -5,12 +5,16 @@ import {
} from "@/context/statusbar-provider";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import { useContext, useEffect, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+
import { FaCheck } from "react-icons/fa";
import { IoIosWarning } from "react-icons/io";
import { MdCircle } from "react-icons/md";
import { Link } from "react-router-dom";
export default function Statusbar() {
+ const { t } = useTranslation(["views/system"]);
+
const { messages, addMessage, clearMessages } = useContext(
StatusBarMessagesContext,
)!;
@@ -50,14 +54,19 @@ export default function Statusbar() {
clearMessages("embeddings-reindex");
addMessage(
"embeddings-reindex",
- `Reindexing embeddings (${Math.floor((reindexState.processed_objects / reindexState.total_objects) * 100)}% complete)`,
+ t("stats.reindexingEmbeddings", {
+ processed: Math.floor(
+ (reindexState.processed_objects / reindexState.total_objects) *
+ 100,
+ ),
+ }),
);
}
if (reindexState.status === "completed") {
clearMessages("embeddings-reindex");
}
}
- }, [reindexState, addMessage, clearMessages]);
+ }, [reindexState, addMessage, clearMessages, t]);
return (
@@ -129,7 +138,7 @@ export default function Statusbar() {
{Object.entries(messages).length === 0 ? (
- System is healthy
+ {t("stats.healthy")}
) : (
Object.entries(messages).map(([key, messageArray]) => (
diff --git a/web/src/components/auth/AuthForm.tsx b/web/src/components/auth/AuthForm.tsx
index 85bd6bccb..a90696dd7 100644
--- a/web/src/components/auth/AuthForm.tsx
+++ b/web/src/components/auth/AuthForm.tsx
@@ -21,16 +21,18 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { AuthContext } from "@/context/auth-context";
+import { useTranslation } from "react-i18next";
interface UserAuthFormProps extends React.HTMLAttributes
{}
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
+ const { t } = useTranslation(["components/auth"]);
const [isLoading, setIsLoading] = React.useState(false);
const { login } = React.useContext(AuthContext);
const formSchema = z.object({
- user: z.string().min(1, "Username is required"),
- password: z.string().min(1, "Password is required"),
+ user: z.string().min(1, t("form.errors.usernameRequired")),
+ password: z.string().min(1, t("form.errors.passwordRequired")),
});
const form = useForm>({
@@ -62,20 +64,20 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
if (err.response?.status === 429) {
- toast.error("Exceeded rate limit. Try again later.", {
+ toast.error(t("form.errors.rateLimit"), {
position: "top-center",
});
} else if (err.response?.status === 401) {
- toast.error("Login failed", {
+ toast.error(t("form.errors.loginFailed"), {
position: "top-center",
});
} else {
- toast.error("Unknown error. Check logs.", {
+ toast.error(t("form.errors.unknownError"), {
position: "top-center",
});
}
} else {
- toast.error("Unknown error. Check console logs.", {
+ toast.error(t("form.errors.webUnkownError"), {
position: "top-center",
});
}
@@ -92,7 +94,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
name="user"
render={({ field }) => (
- User
+ {t("form.user")}
(
- Password
+ {t("form.password")}
{isLoading && }
- Login
+ {t("form.login")}
diff --git a/web/src/components/button/DownloadVideoButton.tsx b/web/src/components/button/DownloadVideoButton.tsx
index 750b35607..094cf9308 100644
--- a/web/src/components/button/DownloadVideoButton.tsx
+++ b/web/src/components/button/DownloadVideoButton.tsx
@@ -3,6 +3,7 @@ import { toast } from "sonner";
import { FaDownload } from "react-icons/fa";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
type DownloadVideoButtonProps = {
source: string;
@@ -17,6 +18,7 @@ export function DownloadVideoButton({
startTime,
className,
}: DownloadVideoButtonProps) {
+ const { t } = useTranslation(["components/input"]);
const formattedDate = formatUnixTimestampToDateTime(startTime, {
strftime_fmt: "%D-%T",
time_style: "medium",
@@ -25,7 +27,7 @@ export function DownloadVideoButton({
const filename = `${camera}_${formattedDate}.mp4`;
const handleDownloadStart = () => {
- toast.success("Your review item video has started downloading.", {
+ toast.success(t("button.downloadVideo.toast.success"), {
position: "top-center",
});
};
@@ -36,7 +38,7 @@ export function DownloadVideoButton({
asChild
className="flex items-center gap-2"
size="sm"
- aria-label="Download Video"
+ aria-label={t("button.downloadVideo.label")}
>
(
`${cameraConfig?.name}-feed`,
@@ -59,17 +61,21 @@ export default function DebugCameraImage({
onClick={handleToggleSettings}
variant="link"
size="sm"
- aria-label="Settings"
+ aria-label={t("debug.options.label")}
>
{" "}
- {showSettings ? "Hide" : "Show"} Options
+
+ {showSettings
+ ? t("debug.options.hideOptions")
+ : t("debug.options.showOptions")}
+
{showSettings ? (
- Options
+ {t("debug.options.title")}
@@ -99,7 +106,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("bbox", isChecked);
}}
/>
- Bounding Box
+ {t("debug.boundingBox")}
- Timestamp
+ {t("debug.timestamp")}
- Zones
+ {t("debug.zones")}
- Mask
+ {t("debug.mask")}
- Motion
+ {t("debug.motion")}
- Regions
+ {t("debug.regions")}
);
diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx
index a41e2252a..1fc37b36f 100644
--- a/web/src/components/card/AnimatedEventCard.tsx
+++ b/web/src/components/card/AnimatedEventCard.tsx
@@ -18,6 +18,7 @@ import { Skeleton } from "../ui/skeleton";
import { Button } from "../ui/button";
import { FaCircleCheck } from "react-icons/fa6";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
type AnimatedEventCardProps = {
event: ReviewSegment;
@@ -29,6 +30,7 @@ export function AnimatedEventCard({
selectedGroup,
updateEvents,
}: AnimatedEventCardProps) {
+ const { t } = useTranslation(["views/events"]);
const { data: config } = useSWR("config");
const apiHost = useApiHost();
@@ -121,7 +123,7 @@ export function AnimatedEventCard({
{
await axios.post(`reviews/viewed`, { ids: [event.id] });
updateEvents();
@@ -130,7 +132,7 @@ export function AnimatedEventCard({
- Mark as Reviewed
+ {t("markAsReviewed")}
)}
{previews != undefined && (
diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx
index c47532df8..fcd970904 100644
--- a/web/src/components/card/ExportCard.tsx
+++ b/web/src/components/card/ExportCard.tsx
@@ -20,6 +20,7 @@ import { MdEditSquare } from "react-icons/md";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil";
+import { useTranslation } from "react-i18next";
type ExportProps = {
className: string;
@@ -36,6 +37,7 @@ export default function ExportCard({
onRename,
onDelete,
}: ExportProps) {
+ const { t } = useTranslation(["views/exports"]);
const [hovered, setHovered] = useState(false);
const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0,
@@ -89,10 +91,8 @@ export default function ExportCard({
}
}}
>
- Rename Export
-
- Enter a new name for this export.
-
+ {t("editExport.title")}
+ {t("editExport.desc")}
{editName && (
<>
submitRename()}
>
- Save
+ {t("button.save", { ns: "common" })}
>
@@ -207,7 +207,7 @@ export default function ExportCard({
{!exportedRecording.in_progress && (
{
onSelect(exportedRecording);
diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx
index e10e009fb..258dc75c7 100644
--- a/web/src/components/card/ReviewCard.tsx
+++ b/web/src/components/card/ReviewCard.tsx
@@ -35,6 +35,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { buttonVariants } from "../ui/button";
+import { Trans, useTranslation } from "react-i18next";
type ReviewCardProps = {
event: ReviewSegment;
@@ -46,6 +47,7 @@ export default function ReviewCard({
currentTime,
onClick,
}: ReviewCardProps) {
+ const { t } = useTranslation(["components/dialog"]);
const { data: config } = useSWR("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
const formattedDate = useFormattedTimestamp(
@@ -82,26 +84,20 @@ export default function ReviewCard({
)
.then((response) => {
if (response.status == 200) {
- toast.success(
- "Successfully started export. View the file in the /exports folder.",
- { position: "top-center" },
- );
- }
- })
- .catch((error) => {
- if (error.response?.data?.message) {
- toast.error(
- `Failed to start export: ${error.response.data.message}`,
- { position: "top-center" },
- );
- } else {
- toast.error(`Failed to start export: ${error.message}`, {
+ toast.success(t("export.toast.success"), {
position: "top-center",
});
}
+ })
+ .catch((error) => {
+ const errorMessage =
+ error.response?.data?.message || error.message || "Unknown error";
+ toast.error(t("export.toast.error.failed", { error: errorMessage }), {
+ position: "top-center",
+ });
});
setOptionsOpen(false);
- }, [event]);
+ }, [event, t]);
const onDelete = useCallback(async () => {
await axios.post(`reviews/delete`, { ids: [event.id] });
@@ -216,24 +212,24 @@ export default function ReviewCard({
>
- Confirm Delete
+
+ {t("recording.confirmDelete.title")}
+
- Are you sure you want to delete all recorded video associated with
- this review item?
-
-
- Hold the Shift key to bypass this dialog in the future.
+
+ recording.confirmDelete.title
+
setOptionsOpen(false)}>
- Cancel
+ {t("button.cancel", { ns: "common" })}
- Delete
+ {t("button.delete", { ns: "common" })}
@@ -247,7 +243,9 @@ export default function ReviewCard({
onClick={onExport}
>
- Export
+
+ {t("recording.button.export")}
+
{!event.has_been_reviewed && (
@@ -257,7 +255,9 @@ export default function ReviewCard({
onClick={onMarkAsReviewed}
>
- Mark as reviewed
+
+ {t("recording.button.markAsReviewed")}
+
)}
@@ -268,7 +268,9 @@ export default function ReviewCard({
>
- {bypassDialogRef.current ? "Delete Now" : "Delete"}
+ {bypassDialogRef.current
+ ? t("recording.button.deleteNow")
+ : t("button.delete", { ns: "common" })}
@@ -286,24 +288,22 @@ export default function ReviewCard({
>
- Confirm Delete
+
+ {t("recording.confirmDelete.title")}
+
- Are you sure you want to delete all recorded video associated with
- this review item?
-
-
- Hold the Shift key to bypass this dialog in the future.
+ recording.confirmDelete.desc
setOptionsOpen(false)}>
- Cancel
+ {t("button.cancel", { ns: "common" })}
- Delete
+ {t("button.delete", { ns: "common" })}
@@ -316,7 +316,7 @@ export default function ReviewCard({
onClick={onExport}
>
- Export
+ {t("recording.button.export")}
{!event.has_been_reviewed && (
-
Mark as reviewed
+
+ {t("recording.button.markAsReviewed")}
+
)}
- {bypassDialogRef.current ? "Delete Now" : "Delete"}
+ {bypassDialogRef.current
+ ? t("recording.button.deleteNow")
+ : t("button.delete", { ns: "common" })}
diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx
index ed98e86b4..b41533f7b 100644
--- a/web/src/components/card/SearchThumbnail.tsx
+++ b/web/src/components/card/SearchThumbnail.tsx
@@ -8,11 +8,11 @@ import Chip from "@/components/indicators/Chip";
import useImageLoaded from "@/hooks/use-image-loaded";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
-import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { SearchResult } from "@/types/search";
import { cn } from "@/lib/utils";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import useContextMenu from "@/hooks/use-contextmenu";
+import { useTranslation } from "react-i18next";
type SearchThumbnailProps = {
searchResult: SearchResult;
@@ -23,6 +23,7 @@ export default function SearchThumbnail({
searchResult,
onClick,
}: SearchThumbnailProps) {
+ const { t } = useTranslation(["views/search"]);
const apiHost = useApiHost();
const { data: config } = useSWR("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
@@ -113,7 +114,7 @@ export default function SearchThumbnail({
.filter(
(item) => item !== undefined && !item.includes("-verified"),
)
- .map((text) => capitalizeFirstLetter(text))
+ .map((text) => t(text, { ns: "objects" }))
.sort()
.join(", ")
.replaceAll("-verified", "")}
diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx
index 33db0c598..a24dfdd70 100644
--- a/web/src/components/card/SearchThumbnailFooter.tsx
+++ b/web/src/components/card/SearchThumbnailFooter.tsx
@@ -6,6 +6,7 @@ import { SearchResult } from "@/types/search";
import ActivityIndicator from "../indicators/activity-indicator";
import SearchResultActions from "../menu/SearchResultActions";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
type SearchThumbnailProps = {
searchResult: SearchResult;
@@ -24,12 +25,15 @@ export default function SearchThumbnailFooter({
showObjectLifecycle,
showSnapshot,
}: SearchThumbnailProps) {
+ const { t } = useTranslation(["views/search"]);
const { data: config } = useSWR("config");
// date
const formattedDate = useFormattedTimestamp(
searchResult.start_time,
- config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
+ config?.ui.time_format == "24hour"
+ ? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
+ : t("time.formattedTimestampExcludeSeconds", { ns: "common" }),
config?.ui.timezone,
);
diff --git a/web/src/components/dynamic/NewReviewData.tsx b/web/src/components/dynamic/NewReviewData.tsx
index 473f187ed..b0fd747b7 100644
--- a/web/src/components/dynamic/NewReviewData.tsx
+++ b/web/src/components/dynamic/NewReviewData.tsx
@@ -3,6 +3,7 @@ import { Button } from "../ui/button";
import { LuRefreshCcw } from "react-icons/lu";
import { MutableRefObject, useMemo } from "react";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
type NewReviewDataProps = {
className: string;
@@ -18,6 +19,7 @@ export default function NewReviewData({
itemsToReview,
pullLatestData,
}: NewReviewDataProps) {
+ const { t } = useTranslation(["views/events"]);
const hasUpdate = useMemo(() => {
if (!reviewItems || !itemsToReview) {
return false;
@@ -36,7 +38,7 @@ export default function NewReviewData({
: "invisible",
"mx-auto bg-gray-400 text-center text-white",
)}
- aria-label="View new review items"
+ aria-label={t("newReviewItems.label")}
onClick={() => {
pullLatestData();
if (contentRef.current) {
@@ -48,7 +50,7 @@ export default function NewReviewData({
}}
>
- New Items To Review
+ {t("newReviewItems.button")}
diff --git a/web/src/components/dynamic/TimeAgo.tsx b/web/src/components/dynamic/TimeAgo.tsx
index 13892180e..594187988 100644
--- a/web/src/components/dynamic/TimeAgo.tsx
+++ b/web/src/components/dynamic/TimeAgo.tsx
@@ -1,3 +1,4 @@
+import { t } from "i18next";
import { FunctionComponent, useEffect, useMemo, useState } from "react";
interface IProp {
@@ -40,7 +41,7 @@ const timeAgo = ({
const elapsed: number = elapsedTime / 1000;
if (elapsed < 10) {
- return "just now";
+ return t("time.justNow");
}
for (let i = 0; i < timeUnits.length; i++) {
@@ -64,11 +65,19 @@ const timeAgo = ({
if (monthDiff > 0) {
const unitAmount = monthDiff;
- return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? "" : "s"} ago`;
+ return t("time.ago", {
+ timeAgo: t(`time.${dense ? timeUnits[i].unit : timeUnits[i].full}`, {
+ time: unitAmount,
+ }),
+ });
}
} else if (elapsed >= timeUnits[i].value) {
const unitAmount: number = Math.floor(elapsed / timeUnits[i].value);
- return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? "" : "s"} ago`;
+ return t("time.ago", {
+ timeAgo: t(`time.${dense ? timeUnits[i].unit : timeUnits[i].full}`, {
+ time: unitAmount,
+ }),
+ });
}
}
return "Invalid Time";
diff --git a/web/src/components/filter/CalendarFilterButton.tsx b/web/src/components/filter/CalendarFilterButton.tsx
index afa70b4e5..524c2a2e6 100644
--- a/web/src/components/filter/CalendarFilterButton.tsx
+++ b/web/src/components/filter/CalendarFilterButton.tsx
@@ -14,6 +14,7 @@ import { DateRangePicker } from "../ui/calendar-range";
import { DateRange } from "react-day-picker";
import { useState } from "react";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
+import { useTranslation } from "react-i18next";
type CalendarFilterButtonProps = {
reviewSummary?: ReviewSummary;
@@ -27,16 +28,17 @@ export default function CalendarFilterButton({
day,
updateSelectedDay,
}: CalendarFilterButtonProps) {
+ const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false);
const selectedDate = useFormattedTimestamp(
day == undefined ? 0 : day?.getTime() / 1000 + 1,
- "%b %-d",
+ t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }),
);
const trigger = (
@@ -46,7 +48,9 @@ export default function CalendarFilterButton({
- {day == undefined ? "Last 24 Hours" : selectedDate}
+ {day == undefined
+ ? t("calendarFilter.last24Hours", { ns: "views/events" })
+ : selectedDate}
);
@@ -61,12 +65,12 @@ export default function CalendarFilterButton({
{
updateSelectedDay(undefined);
}}
>
- Reset
+ {t("button.reset", { ns: "common" })}
>
@@ -93,18 +97,19 @@ export function CalendarRangeFilterButton({
defaultText,
updateSelectedRange,
}: CalendarRangeFilterButtonProps) {
+ const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false);
const selectedDate = useFormattedRange(
range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1,
range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1,
- "%b %-d",
+ t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }),
);
const trigger = (
diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx
index cf6881056..f1215b714 100644
--- a/web/src/components/filter/CameraGroupSelector.tsx
+++ b/web/src/components/filter/CameraGroupSelector.tsx
@@ -70,16 +70,20 @@ import {
MobilePageHeader,
MobilePageTitle,
} from "../mobile/MobilePage";
+
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
import { DialogTrigger } from "@radix-ui/react-dialog";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
+import { Trans, useTranslation } from "react-i18next";
type CameraGroupSelectorProps = {
className?: string;
};
+
export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
+ const { t } = useTranslation(["components/camera"]);
const { data: config } = useSWR("config");
// tooltip
@@ -150,7 +154,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
: "bg-secondary text-secondary-foreground focus:bg-secondary focus:text-secondary-foreground"
}
- aria-label="All Cameras"
+ aria-label={t("menu.live.allCameras", { ns: "common" })}
size="xs"
onClick={() => (group ? setGroup("default", true) : null)}
onMouseEnter={() => (isDesktop ? showTooltip("default") : null)}
@@ -160,8 +164,8 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
-
- All Cameras
+
+ {t("menu.live.allCameras", { ns: "common" })}
@@ -175,7 +179,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
: "bg-secondary text-secondary-foreground"
}
- aria-label="Camera Group"
+ aria-label={t("group.label")}
size="xs"
onClick={() => setGroup(name, group != "default")}
onMouseEnter={() => (isDesktop ? showTooltip(name) : null)}
@@ -202,7 +206,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
setAddGroup(true)}
>
@@ -231,6 +235,7 @@ function NewGroupDialog({
setGroup,
deleteGroup,
}: NewGroupDialogProps) {
+ const { t } = useTranslation(["components/camera"]);
const { mutate: updateConfig } = useSWR("config");
// editing group and state
@@ -273,9 +278,15 @@ function NewGroupDialog({
} else {
setOpen(false);
setEditState("none");
- toast.error(`Failed to save config changes: ${res.statusText}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage: res.statusText,
+ ns: "common",
+ }),
+ {
+ position: "top-center",
+ },
+ );
}
})
.catch((error) => {
@@ -285,7 +296,7 @@ function NewGroupDialog({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to save config changes: ${errorMessage}`, {
+ toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
})
@@ -300,6 +311,7 @@ function NewGroupDialog({
setOpen,
deleteGroup,
deleteGridLayout,
+ t,
],
);
@@ -352,10 +364,8 @@ function NewGroupDialog({
className={cn(isDesktop && "mt-5", "justify-center")}
onClose={() => setOpen(false)}
>
- Camera Groups
-
- Edit camera groups
-
+ {t("group.label")}
+ {t("group.edit")}
{
setEditState("add");
}}
@@ -402,11 +412,9 @@ function NewGroupDialog({
}}
>
- {editState == "add" ? "Add" : "Edit"} Camera Group
+ {editState == "add" ? t("group.add") : t("group.edit")}
-
- Edit camera groups
-
+
{t("group.edit")}
setOpen(false)}>
- Edit Camera Group
- Edit camera group
+ {t("group.edit")}
+
+ {t("group.edit.desc")}
+
- Confirm Delete
+ {t("group.delete.confirm")}
- Are you sure you want to delete the camera group{" "}
- {group[0]} ?
+
+ group.delete.confirm.desc
+
- Cancel
+
+ {t("button.cancel", { ns: "common" })}
+
- Delete
+ {t("button.delete", { ns: "common" })}
@@ -553,16 +568,16 @@ export function CameraGroupRow({
- Edit
+ {t("button.edit", { ns: "common" })}
setDeleteDialogOpen(true)}
>
- Delete
+ {t("button.delete", { ns: "common" })}
@@ -579,7 +594,9 @@ export function CameraGroupRow({
onClick={onEditGroup}
/>
- Edit
+
+ {t("button.edit", { ns: "common" })}
+
@@ -590,7 +607,9 @@ export function CameraGroupRow({
onClick={() => setDeleteDialogOpen(true)}
/>
- Delete
+
+ {t("button.delete", { ns: "common" })}
+
)}
@@ -616,6 +635,7 @@ export function CameraGroupEdit({
onSave,
onCancel,
}: CameraGroupEditProps) {
+ const { t } = useTranslation(["components/camera"]);
const { data: config, mutate: updateConfig } =
useSWR("config");
@@ -635,7 +655,7 @@ export function CameraGroupEdit({
name: z
.string()
.min(2, {
- message: "Camera group name must be at least 2 characters.",
+ message: t("group.name.errorMessage.mustLeastCharacters"),
})
.transform((val: string) => val.trim().replace(/\s+/g, "_"))
.refine(
@@ -646,7 +666,7 @@ export function CameraGroupEdit({
);
},
{
- message: "Camera group name already exists.",
+ message: t("group.name.errorMessage.exists"),
},
)
.refine(
@@ -654,11 +674,11 @@ export function CameraGroupEdit({
return !value.includes(".");
},
{
- message: "Camera group name must not contain a period.",
+ message: t("group.name.errorMessage.nameMustNotPeriod"),
},
)
.refine((value: string) => value.toLowerCase() !== "default", {
- message: "Invalid camera group name.",
+ message: t("group.name.errorMessage.invalid"),
}),
cameras: z.array(z.string()),
@@ -713,18 +733,29 @@ export function CameraGroupEdit({
)
.then(async (res) => {
if (res.status === 200) {
- toast.success(`Camera group (${values.name}) has been saved.`, {
- position: "top-center",
- });
+ toast.success(
+ t("group.toast.success", {
+ name: values.name,
+ }),
+ {
+ position: "top-center",
+ },
+ );
updateConfig();
if (onSave) {
onSave();
}
setAllGroupsStreamingSettings(updatedSettings);
} else {
- toast.error(`Failed to save config changes: ${res.statusText}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage: res.statusText,
+ ns: "common",
+ }),
+ {
+ position: "top-center",
+ },
+ );
}
})
.catch((error) => {
@@ -732,9 +763,13 @@ export function CameraGroupEdit({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to save config changes: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage,
+ ns: "common",
+ }),
+ { position: "top-center" },
+ );
})
.finally(() => {
setIsLoading(false);
@@ -749,6 +784,7 @@ export function CameraGroupEdit({
groupStreamingSettings,
allGroupsStreamingSettings,
setAllGroupsStreamingSettings,
+ t,
],
);
@@ -773,11 +809,11 @@ export function CameraGroupEdit({
name="name"
render={({ field }) => (
- Name
+ {t("group.name.label")}
@@ -793,10 +829,8 @@ export function CameraGroupEdit({
name="cameras"
render={({ field }) => (
- Cameras
-
- Select cameras for this group.
-
+ {t("group.cameras.label")}
+ {t("group.cameras.desc")}
{[
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
@@ -826,7 +860,7 @@ export function CameraGroupEdit({
(
- Icon
+ {t("group.icon")}
- Cancel
+ {t("button.cancel", { ns: "common" })}
{isLoading ? (
-
Saving...
+
{t("button.saving", { ns: "common" })}
) : (
- "Save"
+ t("button.save", { ns: "common" })
)}
diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx
index d9deb87bd..d8277b5f8 100644
--- a/web/src/components/filter/CamerasFilterButton.tsx
+++ b/web/src/components/filter/CamerasFilterButton.tsx
@@ -12,6 +12,7 @@ import { isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import FilterSwitch from "./FilterSwitch";
import { FaVideo } from "react-icons/fa";
+import { useTranslation } from "react-i18next";
type CameraFilterButtonProps = {
allCameras: string[];
@@ -29,6 +30,7 @@ export function CamerasFilterButton({
mainCamera,
updateCameraFilter,
}: CameraFilterButtonProps) {
+ const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false);
const [currentCameras, setCurrentCameras] = useState(
selectedCameras,
@@ -36,15 +38,19 @@ export function CamerasFilterButton({
const buttonText = useMemo(() => {
if (isMobile) {
- return "Cameras";
+ return t("menu.live.cameras", { ns: "common" });
}
if (!selectedCameras || selectedCameras.length == 0) {
- return "All Cameras";
+ return t("menu.live.allCameras", { ns: "common" });
}
-
- return `${selectedCameras.includes("birdseye") ? selectedCameras.length - 1 : selectedCameras.length} Camera${selectedCameras.length !== 1 ? "s" : ""}`;
- }, [selectedCameras]);
+ return t("menu.live.cameras.count", {
+ count: selectedCameras.includes("birdseye")
+ ? selectedCameras.length - 1
+ : selectedCameras.length,
+ ns: "common",
+ });
+ }, [selectedCameras, t]);
// ui
@@ -57,7 +63,7 @@ export function CamerasFilterButton({
const trigger = (
@@ -138,12 +144,13 @@ export function CamerasFilterContent({
setOpen,
updateCameraFilter,
}: CamerasFilterContentProps) {
+ const { t } = useTranslation(["components/filter"]);
return (
<>
{isMobile && (
<>
- Cameras
+ {t("cameras.all.short")}
>
@@ -151,7 +158,7 @@ export function CamerasFilterContent({
{
if (isChecked) {
setCurrentCameras(undefined);
@@ -225,7 +232,7 @@ export function CamerasFilterContent({
{
@@ -233,16 +240,16 @@ export function CamerasFilterContent({
setOpen(false);
}}
>
- Apply
+ {t("button.apply", { ns: "common" })}
{
setCurrentCameras(undefined);
updateCameraFilter(undefined);
}}
>
- Reset
+ {t("button.reset", { ns: "common" })}
>
diff --git a/web/src/components/filter/LogSettingsButton.tsx b/web/src/components/filter/LogSettingsButton.tsx
index e9465bf1d..56c440352 100644
--- a/web/src/components/filter/LogSettingsButton.tsx
+++ b/web/src/components/filter/LogSettingsButton.tsx
@@ -9,6 +9,7 @@ import { Switch } from "../ui/switch";
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
import { cn } from "@/lib/utils";
import FilterSwitch from "./FilterSwitch";
+import { useTranslation } from "react-i18next";
type LogSettingsButtonProps = {
selectedLabels?: LogSeverity[];
@@ -22,23 +23,26 @@ export function LogSettingsButton({
logSettings,
setLogSettings,
}: LogSettingsButtonProps) {
+ const { t } = useTranslation(["components/filter"]);
const trigger = (
- Settings
+
+ {t("menu.settings", { ns: "common" })}
+
);
const content = (
-
Filter
+
{t("filter")}
- Filter logs by severity.
+ {t("logSettings.filterBySeverity")}
-
Loading
+
{t("logSettings.loading")}
- When the log pane is scrolled to the bottom, new logs
- automatically stream as they are added.
+ {t("logSettings.loading.desc")}
{
setLogSettings({
@@ -97,6 +100,7 @@ export function GeneralFilterContent({
selectedLabels,
updateLabelFilter,
}: GeneralFilterContentProps) {
+ const { t } = useTranslation(["components/filter"]);
return (
<>
@@ -105,7 +109,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
- All Logs
+ {t("logSettings.allLogs")}
{
setSelectedReviews([]);
}, [setSelectedReviews]);
@@ -68,22 +70,24 @@ export default function ReviewActionGroup({
>
- Confirm Delete
+
+ {t("recording.confirmDelete.title")}
+
- Are you sure you want to delete all recorded video associated with
- the selected review items?
-
-
- Hold the Shift key to bypass this dialog in the future.
+
+ recording.confirmDelete.desc.selected
+
- Cancel
+
+ {t("button.cancel", { ns: "common" })}
+
- Delete
+ {t("button.delete", { ns: "common" })}
@@ -97,14 +101,14 @@ export default function ReviewActionGroup({
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected}
>
- Unselect
+ {t("button.unselect", { ns: "common" })}
{selectedReviews.length == 1 && (
{
onExport(selectedReviews[0]);
@@ -112,28 +116,38 @@ export default function ReviewActionGroup({
}}
>
- {isDesktop && Export
}
+ {isDesktop && (
+
+ {t("recording.button.export")}
+
+ )}
)}
- {isDesktop && Mark as reviewed
}
+ {isDesktop && (
+
+ {t("recording.button.markAsReviewed")}
+
+ )}
{isDesktop && (
- {bypassDialog ? "Delete Now" : "Delete"}
+ {bypassDialog
+ ? t("recording.button.deleteNow")
+ : t("button.delete", { ns: "common" })}
)}
diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx
index 09eb8092a..fe2b4034b 100644
--- a/web/src/components/filter/ReviewFilterGroup.tsx
+++ b/web/src/components/filter/ReviewFilterGroup.tsx
@@ -23,6 +23,7 @@ import { FilterList, GeneralFilter } from "@/types/filter";
import CalendarFilterButton from "./CalendarFilterButton";
import { CamerasFilterButton } from "./CamerasFilterButton";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
+import { useTranslation } from "react-i18next";
const REVIEW_FILTERS = [
"cameras",
@@ -263,6 +264,7 @@ function ShowReviewFilter({
showReviewed,
setShowReviewed,
}: ShowReviewedFilterProps) {
+ const { t } = useTranslation(["components/filter"]);
const [showReviewedSwitch, setShowReviewedSwitch] = useOptimisticState(
showReviewed,
setShowReviewed,
@@ -278,13 +280,13 @@ function ShowReviewFilter({
}
/>
- Show Reviewed
+ {t("review.showReviewed")}
@@ -320,6 +322,7 @@ function GeneralFilterButton({
selectedZones,
onUpdateFilter,
}: GeneralFilterButtonProps) {
+ const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false);
const [currentFilter, setCurrentFilter] = useState({
labels: selectedLabels,
@@ -348,7 +351,7 @@ function GeneralFilterButton({
selectedLabels?.length || selectedZones?.length ? "select" : "default"
}
className="flex items-center gap-2 capitalize"
- aria-label="Filter"
+ aria-label={t("filter")}
>
- Filter
+ {t("filter")}
);
@@ -439,13 +442,14 @@ export function GeneralFilterContent({
onReset,
onClose,
}: GeneralFilterContentProps) {
+ const { t } = useTranslation(["components/filter"]);
return (
<>
{currentSeverity && (
- All Labels
+ {t("labels.all")}
- All Zones
+ {t("zones.all")}
{
onApply();
onClose();
}}
>
- Apply
+ {t("button.apply", { ns: "common" })}
-
- Reset
+
+ {t("button.reset", { ns: "common" })}
>
@@ -593,6 +600,7 @@ function ShowMotionOnlyButton({
motionOnly,
setMotionOnly,
}: ShowMotionOnlyButtonProps) {
+ const { t } = useTranslation(["views/events"]);
const [motionOnlyButton, setMotionOnlyButton] = useOptimisticState(
motionOnly,
setMotionOnly,
@@ -611,7 +619,7 @@ function ShowMotionOnlyButton({
className="mx-2 cursor-pointer text-primary"
htmlFor="collapse-motion"
>
- Motion only
+ {t("motion.only")}
@@ -619,7 +627,7 @@ function ShowMotionOnlyButton({
setMotionOnlyButton(!motionOnlyButton)}
>
diff --git a/web/src/components/filter/SearchActionGroup.tsx b/web/src/components/filter/SearchActionGroup.tsx
index 32751a56f..565ce10f9 100644
--- a/web/src/components/filter/SearchActionGroup.tsx
+++ b/web/src/components/filter/SearchActionGroup.tsx
@@ -15,6 +15,7 @@ import {
} from "../ui/alert-dialog";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { toast } from "sonner";
+import { Trans, useTranslation } from "react-i18next";
type SearchActionGroupProps = {
selectedObjects: string[];
@@ -26,6 +27,7 @@ export default function SearchActionGroup({
setSelectedObjects,
pullLatestData,
}: SearchActionGroupProps) {
+ const { t } = useTranslation(["views/filter"]);
const onClearSelected = useCallback(() => {
setSelectedObjects([]);
}, [setSelectedObjects]);
@@ -37,7 +39,7 @@ export default function SearchActionGroup({
})
.then((resp) => {
if (resp.status == 200) {
- toast.success("Tracked objects deleted successfully.", {
+ toast.success(t("trackedObjectDelete.toast.success"), {
position: "top-center",
});
setSelectedObjects([]);
@@ -49,11 +51,11 @@ export default function SearchActionGroup({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to delete tracked objects.: ${errorMessage}`, {
+ toast.error(t("trackedObjectDelete.toast.error", { errorMessage }), {
position: "top-center",
});
});
- }, [selectedObjects, setSelectedObjects, pullLatestData]);
+ }, [selectedObjects, setSelectedObjects, pullLatestData, t]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bypassDialog, setBypassDialog] = useState(false);
@@ -78,27 +80,27 @@ export default function SearchActionGroup({
>
- Confirm Delete
+
+ {t("trackedObjectDelete.title")}
+
- Deleting these {selectedObjects.length} tracked objects removes the
- snapshot, any saved embeddings, and any associated object lifecycle
- entries. Recorded footage of these tracked objects in History view
- will NOT be deleted.
-
-
- Are you sure you want to proceed?
-
-
- Hold the Shift key to bypass this dialog in the future.
+
+ trackedObjectDelete.desc
+
- Cancel
+
+ {t("button.cancel", { ns: "common" })}
+
- Delete
+ {t("button.delete", { ns: "common" })}
@@ -112,20 +114,22 @@ export default function SearchActionGroup({
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected}
>
- Unselect
+ {t("button.unselect", { ns: "common" })}
{isDesktop && (
- {bypassDialog ? "Delete Now" : "Delete"}
+ {bypassDialog
+ ? t("button.deleteNow", { ns: "common" })
+ : t("button.delete", { ns: "common" })}
)}
diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx
index 740a3bce7..454ab71d7 100644
--- a/web/src/components/filter/SearchFilterGroup.tsx
+++ b/web/src/components/filter/SearchFilterGroup.tsx
@@ -25,6 +25,8 @@ import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { useTranslation } from "react-i18next";
+
type SearchFilterGroupProps = {
className: string;
filters?: SearchFilters[];
@@ -39,6 +41,7 @@ export default function SearchFilterGroup({
filterList,
onUpdateFilter,
}: SearchFilterGroupProps) {
+ const { t } = useTranslation(["components/filter"]);
const { data: config } = useSWR
("config", {
revalidateOnFocus: false,
});
@@ -195,7 +198,7 @@ export default function SearchFilterGroup({
to: new Date(filter.before * 1000),
}
}
- defaultText={isMobile ? "Dates" : "All Dates"}
+ defaultText={isMobile ? t("dates.all.short") : t("dates.all")}
updateSelectedRange={onUpdateSelectedRange}
/>
)}
@@ -229,6 +232,7 @@ function GeneralFilterButton({
selectedLabels,
updateLabelFilter,
}: GeneralFilterButtonProps) {
+ const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false);
const [currentLabels, setCurrentLabels] = useState(
selectedLabels,
@@ -236,19 +240,21 @@ function GeneralFilterButton({
const buttonText = useMemo(() => {
if (isMobile) {
- return "Labels";
+ return t("labels.all.short");
}
if (!selectedLabels || selectedLabels.length == 0) {
- return "All Labels";
+ return t("labels.all");
}
if (selectedLabels.length == 1) {
- return selectedLabels[0];
+ return t(selectedLabels[0], { ns: "objects" });
}
- return `${selectedLabels.length} Labels`;
- }, [selectedLabels]);
+ return t("labels.count", {
+ count: selectedLabels.length,
+ });
+ }, [selectedLabels, t]);
// ui
@@ -263,7 +269,7 @@ function GeneralFilterButton({
size="sm"
variant={selectedLabels?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize"
- aria-label="Labels"
+ aria-label={t("labels.label")}
>
@@ -331,7 +338,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
- All Labels
+ {t("labels.all")}
(
{
if (isChecked) {
@@ -373,7 +380,7 @@ export function GeneralFilterContent({
{
if (selectedLabels != currentLabels) {
@@ -383,16 +390,16 @@ export function GeneralFilterContent({
onClose();
}}
>
- Apply
+ {t("button.apply", { ns: "common" })}
{
setCurrentLabels(undefined);
updateLabelFilter(undefined);
}}
>
- Reset
+ {t("button.reset", { ns: "common" })}
>
@@ -411,6 +418,7 @@ function SortTypeButton({
selectedSortType,
updateSortType,
}: SortTypeButtonProps) {
+ const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false);
const [currentSortType, setCurrentSortType] = useState<
SearchSortType | undefined
@@ -433,7 +441,7 @@ function SortTypeButton({
: "default"
}
className="flex items-center gap-2 capitalize"
- aria-label="Labels"
+ aria-label={t("labels.label")}
>
- Sort
+ {t("sort.label")}
);
@@ -496,16 +504,16 @@ export function SortTypeContent({
setCurrentSortType,
onClose,
}: SortTypeContentProps) {
+ const { t } = useTranslation(["components/filter"]);
const sortLabels = {
- date_asc: "Date (Ascending)",
- date_desc: "Date (Descending)",
- score_asc: "Object Score (Ascending)",
- score_desc: "Object Score (Descending)",
- speed_asc: "Estimated Speed (Ascending)",
- speed_desc: "Estimated Speed (Descending)",
- relevance: "Relevance",
+ date_asc: t("sort.dateAsc"),
+ date_desc: t("sort.dateDesc"),
+ score_asc: t("sort.scoreAsc"),
+ score_desc: t("sort.scoreDesc"),
+ speed_asc: t("sort.speedAsc"),
+ speed_desc: t("sort.speedDesc"),
+ relevance: t("sort.relevance"),
};
-
return (
<>
@@ -548,7 +556,7 @@ export function SortTypeContent({
{
if (selectedSortType != currentSortType) {
@@ -558,16 +566,16 @@ export function SortTypeContent({
onClose();
}}
>
- Apply
+ {t("button.apply", { ns: "common" })}
{
setCurrentSortType(undefined);
updateSortType(undefined);
}}
>
- Reset
+ {t("button.reset", { ns: "common" })}
>
diff --git a/web/src/components/filter/ZoneMaskFilter.tsx b/web/src/components/filter/ZoneMaskFilter.tsx
index 28d86b499..5fd59cd76 100644
--- a/web/src/components/filter/ZoneMaskFilter.tsx
+++ b/web/src/components/filter/ZoneMaskFilter.tsx
@@ -7,6 +7,7 @@ import { PolygonType } from "@/types/canvas";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
+import { useTranslation } from "react-i18next";
type ZoneMaskFilterButtonProps = {
selectedZoneMask?: PolygonType[];
@@ -16,12 +17,13 @@ export function ZoneMaskFilterButton({
selectedZoneMask,
updateZoneMaskFilter,
}: ZoneMaskFilterButtonProps) {
+ const { t } = useTranslation(["components/filter"]);
const trigger = (
- Filter
+ {t("filter")}
);
@@ -67,6 +69,7 @@ export function GeneralFilterContent({
selectedZoneMask,
updateZoneMaskFilter,
}: GeneralFilterContentProps) {
+ const { t } = useTranslation(["components/filter"]);
return (
<>
@@ -75,7 +78,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
- All Masks and Zones
+ {t("labels.all")}
- {item
- .replace(/_/g, " ")
- .replace(/\b\w/g, (char) => char.toUpperCase()) + "s"}
+ {t(
+ "masksAndZones." +
+ item
+ .replace(/_([a-z])/g, (letter) => letter.toUpperCase())
+ .replace("_", "") +
+ "s.label",
+ { ns: "views/settings" },
+ )}
("config", {
revalidateOnFocus: false,
});
@@ -126,7 +128,9 @@ export function CameraLineGraph({
className="size-2"
style={{ color: GRAPH_COLORS[labelIdx] }}
/>
- {label}
+
+ {t("cameras.label." + label)}
+
{lastValues[labelIdx]}
{unit}
diff --git a/web/src/components/graph/CombinedStorageGraph.tsx b/web/src/components/graph/CombinedStorageGraph.tsx
index 7f10f0ab5..5e988b52b 100644
--- a/web/src/components/graph/CombinedStorageGraph.tsx
+++ b/web/src/components/graph/CombinedStorageGraph.tsx
@@ -16,7 +16,9 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { getUnitSize } from "@/utils/storageUtil";
+
import { CiCircleAlert } from "react-icons/ci";
+import { useTranslation } from "react-i18next";
type CameraStorage = {
[key: string]: {
@@ -41,6 +43,8 @@ export function CombinedStorageGraph({
cameraStorage,
totalStorage,
}: CombinedStorageGraphProps) {
+ const { t } = useTranslation(["views/system"]);
+
const { theme, systemTheme } = useTheme();
const entities = Object.keys(cameraStorage);
@@ -176,10 +180,12 @@ export function CombinedStorageGraph({
- Camera
- Storage Used
- Percentage of Total Used
- Bandwidth
+ {t("storage.cameraStorage.camera")}
+ {t("storage.cameraStorage.storageUsed")}
+
+ {t("storage.cameraStorage.percentageOfTotalUsed")}
+
+ {t("storage.cameraStorage.bandwidth")}
@@ -191,26 +197,29 @@ export function CombinedStorageGraph({
className="size-3 rounded-md"
style={{ backgroundColor: item.color }}
>
- {item.name.replaceAll("_", " ")}
+ {item.name === "Unused"
+ ? t("storage.cameraStorage.unused")
+ : item.name.replaceAll("_", " ")}
{item.name === "Unused" && (
- 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.
+ {t("storage.cameraStorage.unused.tips")}
diff --git a/web/src/components/icons/IconPicker.tsx b/web/src/components/icons/IconPicker.tsx
index d58b57ead..b74029e0b 100644
--- a/web/src/components/icons/IconPicker.tsx
+++ b/web/src/components/icons/IconPicker.tsx
@@ -12,6 +12,8 @@ import Heading from "../ui/heading";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
+import { useTranslation } from "react-i18next";
+
export type IconName = keyof typeof LuIcons;
export type IconElement = {
@@ -30,6 +32,7 @@ export default function IconPicker({
selectedIcon,
setSelectedIcon,
}: IconPickerProps) {
+ const { t } = useTranslation(["components/icons"]);
const [open, setOpen] = useState(false);
const containerRef = useRef(null);
const [searchTerm, setSearchTerm] = useState("");
@@ -68,9 +71,9 @@ export default function IconPicker({
{!selectedIcon?.name || !selectedIcon?.Icon ? (
- Select an icon
+ {t("iconPicker.selectIcon")}
) : (
@@ -101,7 +104,7 @@ export default function IconPicker({
className="flex max-h-[50dvh] flex-col overflow-y-hidden md:max-h-[30dvh]"
>
-
Select an icon
+
{t("iconPicker.selectIcon")}
setSearchTerm(e.target.value)}
diff --git a/web/src/components/input/InputWithTags.tsx b/web/src/components/input/InputWithTags.tsx
index 3ae78e70a..1f9f17626 100644
--- a/web/src/components/input/InputWithTags.tsx
+++ b/web/src/components/input/InputWithTags.tsx
@@ -51,6 +51,7 @@ import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { MdImageSearch } from "react-icons/md";
+import { Trans, useTranslation } from "react-i18next";
type InputWithTagsProps = {
inputFocused: boolean;
@@ -73,6 +74,7 @@ export default function InputWithTags({
setSearch,
allSuggestions,
}: InputWithTagsProps) {
+ const { t } = useTranslation(["views/search"]);
const { data: config } = useSWR("config", {
revalidateOnFocus: false,
});
@@ -236,12 +238,9 @@ export default function InputWithTags({
filters.after &&
timestamp <= filters.after * 1000
) {
- toast.error(
- "The 'before' date must be later than the 'after' date.",
- {
- position: "top-center",
- },
- );
+ toast.error(t("filter.toast.error.beforeDateBeLaterAfter"), {
+ position: "top-center",
+ });
return;
}
if (
@@ -249,12 +248,9 @@ export default function InputWithTags({
filters.before &&
timestamp >= filters.before * 1000
) {
- toast.error(
- "The 'after' date must be earlier than the 'before' date.",
- {
- position: "top-center",
- },
- );
+ toast.error(t("afterDatebeEarlierBefore"), {
+ position: "top-center",
+ });
return;
}
if (type === "before") {
@@ -274,7 +270,7 @@ export default function InputWithTags({
score > filters.max_score * 100
) {
toast.error(
- "The 'min_score' must be less than or equal to the 'max_score'.",
+ t("filter.toast.error.minScoreMustBeLessOrEqualMaxScore"),
{
position: "top-center",
},
@@ -287,7 +283,7 @@ export default function InputWithTags({
score < filters.min_score * 100
) {
toast.error(
- "The 'max_score' must be greater than or equal to the 'min_score'.",
+ t("filter.toast.error.maxScoreMustBeGreaterOrEqualMinScore"),
{
position: "top-center",
},
@@ -308,7 +304,7 @@ export default function InputWithTags({
speed > filters.max_speed
) {
toast.error(
- "The 'min_speed' must be less than or equal to the 'max_speed'.",
+ t("filter.toast.error.minSpeedMustBeLessOrEqualMaxSpeed"),
{
position: "top-center",
},
@@ -321,7 +317,7 @@ export default function InputWithTags({
speed < filters.min_speed
) {
toast.error(
- "The 'max_speed' must be greater than or equal to the 'min_speed'.",
+ t("filter.toast.error.maxSpeedMustBeGreaterOrEqualMinSpeed"),
{
position: "top-center",
},
@@ -380,7 +376,7 @@ export default function InputWithTags({
setCurrentFilterType(null);
}
},
- [filters, setFilters, allSuggestions],
+ [filters, setFilters, allSuggestions, t],
);
function formatFilterValues(
@@ -408,16 +404,26 @@ export default function InputWithTags({
return Math.round(Number(filterValues) * 100).toString() + "%";
} else if (filterType === "min_speed" || filterType === "max_speed") {
return (
- filterValues + (config?.ui.unit_system == "metric" ? " kph" : " mph")
+ filterValues +
+ " " +
+ (config?.ui.unit_system == "metric"
+ ? t("unit.speed.kph", { ns: "common" })
+ : t("unit.speed.mph", { ns: "common" }))
);
} else if (
filterType === "has_clip" ||
filterType === "has_snapshot" ||
filterType === "is_submitted"
) {
- return filterValues ? "Yes" : "No";
+ return filterValues
+ ? t("button.yes", { ns: "common" })
+ : t("button.no", { ns: "common" });
+ } else if (filterType === "labels") {
+ return t(filterValues as string, { ns: "objects" });
+ } else if (filterType === "search_type") {
+ return t("filter.searchType." + (filterValues as string));
} else {
- return filterValues as string;
+ return (filterValues as string).replaceAll("_", " ");
}
}
@@ -653,7 +659,7 @@ export default function InputWithTags({
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
className="text-md h-9 pr-32"
- placeholder="Search..."
+ placeholder={t("placeholder.search")}
/>
{(search || Object.keys(filters).length > 0) && (
@@ -665,7 +671,7 @@ export default function InputWithTags({
/>
- Clear search
+ {t("button.clear")}
)}
@@ -679,7 +685,7 @@ export default function InputWithTags({
/>
- Save search
+ {t("button.save")}
)}
@@ -688,12 +694,14 @@ export default function InputWithTags({
- Similarity search active
+
+ {t("similaritySearch.active")}
+
)}
@@ -702,10 +710,10 @@ export default function InputWithTags({
0
@@ -717,43 +725,24 @@ export default function InputWithTags({
-
How to use text filters
+
{t("filter.tips.title")}
- Filters help you narrow down your search results. Here's how
- to use them in the input field:
+ {t("filter.tips.desc")}
-
-
- Type a filter name followed by a colon (e.g., "cameras:").
-
-
- Select a value from the suggestions or type your own.
-
-
- Use multiple filters by adding them one after another with
- a space in between.
-
-
- Date filters (before: and after:) use{" "}
- {getIntlDateFormat()} format.
-
-
- Time range filter uses{" "}
-
- {config?.ui.time_format == "24hour"
+
- format.
-
- Remove filters by clicking the 'x' next to them.
-
+ : "3:00PM-4:00PM",
+ }}
+ >
+ filter.tips.desc.step
+
- Example:{" "}
-
- cameras:front_door label:person before:01012024
- time_range:3:00PM-4:00PM
-
+ filter.tips.desc.example
@@ -780,27 +769,27 @@ export default function InputWithTags({
)}
>
{!currentFilterType && inputValue && (
-
+
handleSearch(inputValue)}
>
- Search for "{inputValue}"
+ {t("searchFor", { inputValue })}
)}
{(Object.keys(filters).filter((key) => key !== "query").length > 0 ||
isSimilaritySearch) && (
-
+
{isSimilaritySearch && (
- Similarity Search
+ {t("similaritySearch.title")}
@@ -816,8 +805,8 @@ export default function InputWithTags({
key={`${filterType}-${index}`}
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm capitalize text-green-800"
>
- {filterType.replaceAll("_", " ")}:{" "}
- {value.replaceAll("_", " ")}
+ {t("filter.label." + filterType)}:{" "}
+ {formatFilterValues(filterType, value)}
removeFilter(filterType as FilterType, value)
@@ -835,10 +824,12 @@ export default function InputWithTags({
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm capitalize text-green-800"
>
{filterType === "event_id"
- ? "Tracked Object ID"
+ ? t("trackedObjectId")
: filterType === "is_submitted"
- ? "Submitted to Frigate+"
- : filterType.replaceAll("_", " ")}
+ ? t("features.submittedToFrigatePlus.label", {
+ ns: "components/filter",
+ })
+ : t("filter.label." + filterType)}
: {formatFilterValues(filterType, filterValues)}
@@ -863,7 +854,7 @@ export default function InputWithTags({
!inputValue &&
searchHistoryLoaded &&
(searchHistory?.length ?? 0) > 0 && (
-
+
{searchHistory?.map((suggestion, index) => (
- Delete saved search
+ {t("button.delete")}
@@ -892,7 +883,11 @@ export default function InputWithTags({
)}
{filterSuggestions(suggestions)
.filter(
@@ -905,7 +900,12 @@ export default function InputWithTags({
className="cursor-pointer"
onSelect={() => handleSuggestionClick(suggestion)}
>
+ {currentFilterType
+ ? formatFilterValues(currentFilterType, suggestion)
+ : t("filter.label." + suggestion)}
+ {" ("}
{suggestion}
+ {")"}
))}
diff --git a/web/src/components/input/SaveSearchDialog.tsx b/web/src/components/input/SaveSearchDialog.tsx
index 322a76421..5eebdda74 100644
--- a/web/src/components/input/SaveSearchDialog.tsx
+++ b/web/src/components/input/SaveSearchDialog.tsx
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
import { toast } from "sonner";
+import { useTranslation } from "react-i18next";
type SaveSearchDialogProps = {
existingNames: string[];
@@ -26,15 +27,22 @@ export function SaveSearchDialog({
onClose,
onSave,
}: SaveSearchDialogProps) {
+ const { t } = useTranslation(["components/dialog"]);
+
const [searchName, setSearchName] = useState("");
const handleSave = () => {
if (searchName.trim()) {
onSave(searchName.trim());
setSearchName("");
- toast.success(`Search (${searchName.trim()}) has been saved.`, {
- position: "top-center",
- });
+ toast.success(
+ t("search.saveSearch.success", {
+ searchName: searchName.trim(),
+ }),
+ {
+ position: "top-center",
+ },
+ );
onClose();
}
};
@@ -54,34 +62,36 @@ export function SaveSearchDialog({
}}
>
- Save Search
+ {t("search.saveSearch.label")}
- Provide a name for this saved search.
+ {t("search.saveSearch.desc")}
setSearchName(e.target.value)}
- placeholder="Enter a name for your search"
+ placeholder={t("search.saveSearch.placeholder")}
/>
{overwrite && (
- {searchName} already exists. Saving will overwrite the existing
- value.
+ {t("search.saveSearch.overwrite", { searchName })}
)}
-
- Cancel
+
+ {t("button.cancel", { ns: "common" })}
- Save
+ {t("button.save", { ns: "common" })}
diff --git a/web/src/components/menu/AccountSettings.tsx b/web/src/components/menu/AccountSettings.tsx
index 7e948308f..061a99e2a 100644
--- a/web/src/components/menu/AccountSettings.tsx
+++ b/web/src/components/menu/AccountSettings.tsx
@@ -20,16 +20,19 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { DialogClose } from "../ui/dialog";
import { LuLogOut, LuSquarePen } from "react-icons/lu";
import useSWR from "swr";
+
import { useState } from "react";
import axios from "axios";
import { toast } from "sonner";
import SetPasswordDialog from "../overlay/SetPasswordDialog";
+import { useTranslation } from "react-i18next";
type AccountSettingsProps = {
className?: string;
};
export default function AccountSettings({ className }: AccountSettingsProps) {
+ const { t } = useTranslation(["views/settings"]);
const { data: profile } = useSWR("profile");
const { data: config } = useSWR("config");
const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
@@ -48,7 +51,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
.then((response) => {
if (response.status === 200) {
setPasswordDialogOpen(false);
- toast.success("Password updated successfully.", {
+ toast.success(t("users.toast.success.updatePassword"), {
position: "top-center",
});
}
@@ -58,9 +61,14 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Error setting password: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("users.toast.error.setPasswordFailed", {
+ errorMessage,
+ }),
+ {
+ position: "top-center",
+ },
+ );
});
};
@@ -83,7 +91,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
- Account
+ {t("menu.user.account", { ns: "common" })}
@@ -95,8 +103,13 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
>
- Current User: {profile?.username || "anonymous"}{" "}
- {profile?.role && `(${profile.role})`}
+ {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" })})`}
{profile?.username && profile.username !== "anonymous" && (
@@ -104,22 +117,22 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
- aria-label="Set Password"
+ aria-label={t("menu.user.setPassword", { ns: "common" })}
onClick={() => setPasswordDialogOpen(true)}
>
-
Set Password
+
{t("menu.user.setPassword", { ns: "common" })}
)}
- Logout
+ {t("menu.user.logout", { ns: "common" })}
diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx
index 55d180a42..45e567868 100644
--- a/web/src/components/menu/GeneralSettings.tsx
+++ b/web/src/components/menu/GeneralSettings.tsx
@@ -1,6 +1,7 @@
import {
LuActivity,
LuGithub,
+ LuLanguages,
LuLifeBuoy,
LuList,
LuLogOut,
@@ -10,6 +11,7 @@ import {
LuSettings,
LuSun,
LuSunMoon,
+ LuEarth,
} from "react-icons/lu";
import {
DropdownMenu,
@@ -52,21 +54,28 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
import useSWR from "swr";
import RestartDialog from "../overlay/dialog/RestartDialog";
+
+import { useLanguage } from "@/context/language-provider";
import { useIsAdmin } from "@/hooks/use-is-admin";
import SetPasswordDialog from "../overlay/SetPasswordDialog";
import { toast } from "sonner";
import axios from "axios";
import { FrigateConfig } from "@/types/frigateConfig";
+import { useTranslation } from "react-i18next";
type GeneralSettingsProps = {
className?: string;
};
export default function GeneralSettings({ className }: GeneralSettingsProps) {
+ const { t } = useTranslation(["common"]);
const { data: profile } = useSWR("profile");
const { data: config } = useSWR("config");
const logoutUrl = config?.proxy?.logout_url || "/api/logout";
+ // settings
+
+ const { language, setLanguage, systemLanguage } = useLanguage();
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
@@ -90,9 +99,12 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
.then((response) => {
if (response.status === 200) {
setPasswordDialogOpen(false);
- toast.success("Password updated successfully.", {
- position: "top-center",
- });
+ toast.success(
+ t("users.toast.success.updatePassword", { ns: "views/settings" }),
+ {
+ position: "top-center",
+ },
+ );
}
})
.catch((error) => {
@@ -100,9 +112,15 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Error setting password: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("users.toast.error.setPasswordFailed", {
+ ns: "views/settings",
+ errorMessage,
+ }),
+ {
+ position: "top-center",
+ },
+ );
});
};
@@ -126,7 +144,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
- Settings
+ {t("menu.settings")}
@@ -150,8 +168,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
{isMobile && (
- Current User: {profile?.username || "anonymous"}{" "}
- {profile?.role && `(${profile.role})`}
+ {t("menu.user.current", {
+ user: profile?.username || t("menu.user.anonymous"),
+ })}{" "}
+ {t("role." + profile?.role) &&
+ `(${t("role." + profile?.role)})`}
setPasswordDialogOpen(true)}
>
- Set Password
+ {t("menu.user.setPassword")}
)}
- Logout
+ {t("menu.user.logout", { ns: "common" })}
)}
{isAdmin && (
<>
- System
+ {t("menu.system")}
@@ -197,10 +218,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
- aria-label="System metrics"
+ aria-label={t("menu.systemMetrics")}
>
- System metrics
+ {t("menu.systemMetrics")}
@@ -210,10 +231,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
- aria-label="System logs"
+ aria-label={t("menu.systemLogs")}
>
- System logs
+ {t("menu.systemLogs")}
@@ -222,7 +243,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
- Configuration
+ {t("menu.configuration")}
@@ -233,10 +254,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
- aria-label="Settings"
+ aria-label={t("menu.settings")}
>
- Settings
+ {t("menu.settings")}
{isAdmin && (
@@ -248,10 +269,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
- aria-label="Configuration editor"
+ aria-label={t("menu.configurationEditor")}
>
- Configuration editor
+ {t("menu.configurationEditor")}
>
@@ -271,7 +292,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
)}
- Appearance
+ {t("menu.appearance")}
@@ -280,8 +301,8 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
>
-
- Dark Mode
+
+ {t("menu.languages")}
setTheme("light")}
+ aria-label={t("menu.language.en")}
+ onClick={() => setLanguage("en")}
>
- {theme === "light" ? (
+ {language.trim() === "en" ? (
<>
-
- Light
+
+ {t("menu.language.en")}
>
) : (
- Light
+ {t("menu.language.en")}
)}
setTheme("dark")}
+ aria-label={t("menu.language.zhCN")}
+ onClick={() => setLanguage("zh-CN")}
>
- {theme === "dark" ? (
+ {language === "zh-CN" ? (
<>
-
- Dark
+
+ {t("menu.language.zhCN")}
>
) : (
- Dark
+
+ {t("menu.language.zhCN")}
+
)}
setTheme("system")}
+ aria-label={t("menu.language.withSystem.label")}
+ onClick={() => setLanguage(systemLanguage)}
>
- {theme === "system" ? (
+ {language === systemLanguage ? (
<>
-
- System
+
+ {t("menu.withSystem")}
>
) : (
- System
+ {t("menu.withSystem")}
)}
@@ -354,7 +377,84 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
}
>
- Theme
+ {t("menu.darkMode.label")}
+
+
+
+
+ setTheme("light")}
+ >
+ {theme === "light" ? (
+ <>
+
+ {t("menu.darkMode.light")}
+ >
+ ) : (
+
+ {t("menu.darkMode.light")}
+
+ )}
+
+ setTheme("dark")}
+ >
+ {theme === "dark" ? (
+ <>
+
+ {t("menu.darkMode.dark")}
+ >
+ ) : (
+
+ {t("menu.darkMode.dark")}
+
+ )}
+
+ setTheme("system")}
+ >
+ {theme === "system" ? (
+ <>
+
+ {t("menu.withSystem")}
+ >
+ ) : (
+ {t("menu.withSystem")}
+ )}
+
+
+
+
+
+
+
+ {t("menu.theme.label")}
- {friendlyColorSchemeName(scheme)}
+ {t(friendlyColorSchemeName(scheme))}
>
) : (
- {friendlyColorSchemeName(scheme)}
+ {t(friendlyColorSchemeName(scheme))}
)}
@@ -390,7 +490,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
- Help
+ {t("menu.help")}
@@ -398,10 +498,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
- aria-label="Frigate documentation"
+ aria-label={t("menu.documentation.label")}
>
- Documentation
+ {t("menu.documentation")}
setRestartDialogOpen(true)}
>
- Restart Frigate
+ {t("menu.restart")}
>
)}
diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx
index 9c775e0ac..52af5b9b7 100644
--- a/web/src/components/menu/LiveContextMenu.tsx
+++ b/web/src/components/menu/LiveContextMenu.tsx
@@ -44,6 +44,7 @@ import {
useNotifications,
useNotificationSuspend,
} from "@/api/ws";
+import { useTranslation } from "react-i18next";
type LiveContextMenuProps = {
className?: string;
@@ -85,6 +86,7 @@ export default function LiveContextMenu({
config,
children,
}: LiveContextMenuProps) {
+ const { t } = useTranslation("views/live");
const [showSettings, setShowSettings] = useState(false);
// camera enabled
@@ -209,7 +211,7 @@ export default function LiveContextMenu({
// notifications
const notificationsEnabledInConfig =
- config?.cameras[camera].notifications.enabled_in_config;
+ config?.cameras[camera]?.notifications?.enabled_in_config;
const { payload: notificationState, send: sendNotification } =
useNotifications(camera);
@@ -234,14 +236,19 @@ export default function LiveContextMenu({
};
const formatSuspendedUntil = (timestamp: string) => {
- if (timestamp === "0") return "Frigate restarts.";
+ // Some languages require a change in word order
+ if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
- return formatUnixTimestampToDateTime(Number.parseInt(timestamp), {
+ const time = formatUnixTimestampToDateTime(Number.parseInt(timestamp), {
time_style: "medium",
date_style: "medium",
timezone: config?.ui.timezone,
- strftime_fmt: `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`,
+ strftime_fmt:
+ config?.ui.time_format == "24hour"
+ ? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
+ : t("time.formattedTimestampExcludeSeconds", { ns: "common" }),
});
+ return t("time.untilForTime", { ns: "common", time });
};
return (
@@ -256,7 +263,7 @@ export default function LiveContextMenu({
{preferredLiveMode == "jsmpeg" && isRestreamed && (
-
Low-bandwidth mode
+
{t("lowBandwidthMode")}
)}
@@ -265,7 +272,7 @@ export default function LiveContextMenu({
-
Audio
+
{t("audio")}
sendEnabled(isEnabled ? "OFF" : "ON")}
>
- {isEnabled ? "Disable" : "Enable"} Camera
+ {isEnabled ? t("camera.disable") : t("camera.enable")}
@@ -302,7 +309,7 @@ export default function LiveContextMenu({
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? muteAll : undefined}
>
-
Mute All Cameras
+
{t("muteCameras.enable")}
@@ -310,7 +317,7 @@ export default function LiveContextMenu({
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? unmuteAll : undefined}
>
- Unmute All Cameras
+ {t("muteCameras.disable")}
@@ -320,7 +327,9 @@ export default function LiveContextMenu({
onClick={isEnabled ? toggleStats : undefined}
>
- {statsState ? "Hide" : "Show"} Stream Stats
+ {statsState
+ ? t("streamStats.disable")
+ : t("streamStats.enable")}
@@ -333,7 +342,9 @@ export default function LiveContextMenu({
: undefined
}
>
- Debug View
+
+ {t("streaming.debugView", { ns: "components/dialog" })}
+
{cameraGroup && cameraGroup !== "default" && (
@@ -344,7 +355,7 @@ export default function LiveContextMenu({
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? () => setShowSettings(true) : undefined}
>
-
Streaming Settings
+
{t("streamingSettings")}
>
@@ -357,7 +368,9 @@ export default function LiveContextMenu({
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? resetPreferredLiveMode : undefined}
>
- Reset
+
+ {t("button.reset", { ns: "common" })}
+
>
@@ -368,7 +381,7 @@ export default function LiveContextMenu({
- Notifications
+ {t("notifications")}
@@ -379,25 +392,29 @@ export default function LiveContextMenu({
{isSuspended ? (
<>
- Suspended
+
+ {t("button.suspended", { ns: "common" })}
+
>
) : (
<>
- Enabled
+
+ {t("button.enabled", { ns: "common" })}
+
>
)}
>
) : (
<>
- Disabled
+ {t("button.disabled", { ns: "common" })}
>
)}
{isSuspended && (
- Until {formatSuspendedUntil(notificationSuspendUntil)}
+ {formatSuspendedUntil(notificationSuspendUntil)}
)}
@@ -418,9 +435,11 @@ export default function LiveContextMenu({
>
{notificationState === "ON" ? (
- Unsuspend
+
+ {t("button.unsuspended", { ns: "common" })}
+
) : (
- Enable
+ {t("button.enable", { ns: "common" })}
)}
@@ -431,7 +450,7 @@ export default function LiveContextMenu({
- Suspend for:
+ {t("suspend.forTime")}
handleSuspend("5") : undefined
}
>
- 5 minutes
+ {t("time.5minutes", { ns: "common" })}
- 10 minutes
+ {t("time.10minutes", { ns: "common" })}
- 30 minutes
+ {t("time.30minutes", { ns: "common" })}
- 1 hour
+ {t("time.1hour", { ns: "common" })}
- 12 hours
+ {t("time.12hours", { ns: "common" })}
- 24 hours
+ {t("time.24hours", { ns: "common" })}
- Until restart
+ {t("time.untilRestart", { ns: "common" })}
diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx
index 4d1fd4966..b6d664b39 100644
--- a/web/src/components/menu/SearchResultActions.tsx
+++ b/web/src/components/menu/SearchResultActions.tsx
@@ -40,6 +40,8 @@ import {
} from "@/components/ui/tooltip";
import useSWR from "swr";
+import { Trans, useTranslation } from "react-i18next";
+
type SearchResultActionsProps = {
searchResult: SearchResult;
findSimilar: () => void;
@@ -59,6 +61,8 @@ export default function SearchResultActions({
isContextMenu = false,
children,
}: SearchResultActionsProps) {
+ const { t } = useTranslation(["views/explore"]);
+
const { data: config } = useSWR("config");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -68,7 +72,7 @@ export default function SearchResultActions({
.delete(`events/${searchResult.id}`)
.then((resp) => {
if (resp.status == 200) {
- toast.success("Tracked object deleted successfully.", {
+ toast.success(t("searchResult.deleteTrackedObject.toast.success"), {
position: "top-center",
});
refreshResults();
@@ -79,9 +83,12 @@ export default function SearchResultActions({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to delete tracked object: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("searchResult.deleteTrackedObject.toast.error", { errorMessage }),
+ {
+ position: "top-center",
+ },
+ );
});
};
@@ -90,45 +97,45 @@ export default function SearchResultActions({
const menuItems = (
<>
{searchResult.has_clip && (
-
+
- Download video
+ {t("itemMenu.downloadVideo")}
)}
{searchResult.has_snapshot && (
-
+
- Download snapshot
+ {t("itemMenu.downloadSnapshot.label")}
)}
{searchResult.data.type == "object" && (
- View object lifecycle
+ {t("itemMenu.viewObjectLifecycle.label")}
)}
{config?.semantic_search?.enabled && isContextMenu && (
- Find similar
+ {t("itemMenu.findSimilar.label")}
)}
{isMobileOnly &&
@@ -137,17 +144,20 @@ export default function SearchResultActions({
searchResult.end_time &&
searchResult.data.type == "object" &&
!searchResult.plus_id && (
-
+
- Submit to Frigate+
+ {t("itemMenu.submitToPlus")}
)}
setDeleteDialogOpen(true)}
>
- Delete
+ {t("button.delete", { ns: "common" })}
>
);
@@ -160,24 +170,20 @@ export default function SearchResultActions({
>
- Confirm Delete
+ {t("dialog.confirmDelete")}
- Deleting this tracked object removes the snapshot, any saved
- embeddings, and any associated object lifecycle entries. Recorded
- footage of this tracked object in History view will NOT be
- deleted.
-
-
- Are you sure you want to proceed?
+ dialog.confirmDelete.desc
- Cancel
+
+ {t("button.cancel", { ns: "common" })}
+
- Delete
+ {t("button.delete", { ns: "common" })}
@@ -198,7 +204,9 @@ export default function SearchResultActions({
onClick={findSimilar}
/>
- Find similar
+
+ {t("itemMenu.findSimilar.label")}
+
)}
@@ -215,7 +223,9 @@ export default function SearchResultActions({
onClick={showSnapshot}
/>
- Submit to Frigate+
+
+ {t("itemMenu.submitToPlus.label")}
+
)}
diff --git a/web/src/components/mobile/MobilePage.tsx b/web/src/components/mobile/MobilePage.tsx
index 169b5e524..8330f0a64 100644
--- a/web/src/components/mobile/MobilePage.tsx
+++ b/web/src/components/mobile/MobilePage.tsx
@@ -5,6 +5,7 @@ import { IoMdArrowRoundBack } from "react-icons/io";
import { cn } from "@/lib/utils";
import { isPWA } from "@/utils/isPWA";
import { Button } from "@/components/ui/button";
+import { useTranslation } from "react-i18next";
const MobilePageContext = createContext<{
open: boolean;
@@ -138,6 +139,7 @@ export function MobilePageHeader({
onClose,
...props
}: MobilePageHeaderProps) {
+ const { t } = useTranslation(["common"]);
const context = useContext(MobilePageContext);
if (!context)
throw new Error("MobilePageHeader must be used within MobilePage");
@@ -160,7 +162,7 @@ export function MobilePageHeader({
>
diff --git a/web/src/components/navigation/NavItem.tsx b/web/src/components/navigation/NavItem.tsx
index 98b54b3a7..f8ee7eb0d 100644
--- a/web/src/components/navigation/NavItem.tsx
+++ b/web/src/components/navigation/NavItem.tsx
@@ -9,6 +9,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import { NavData } from "@/types/navigation";
import { IconType } from "react-icons";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
const variants = {
primary: {
@@ -34,6 +35,7 @@ export default function NavItem({
Icon,
onClick,
}: NavItemProps) {
+ const { t } = useTranslation(["common"]);
if (item.enabled == false) {
return;
}
@@ -60,7 +62,7 @@ export default function NavItem({
{content}
- {item.title}
+ {t(item.title)}
diff --git a/web/src/components/overlay/CameraInfoDialog.tsx b/web/src/components/overlay/CameraInfoDialog.tsx
index c0136a410..cc13b23ca 100644
--- a/web/src/components/overlay/CameraInfoDialog.tsx
+++ b/web/src/components/overlay/CameraInfoDialog.tsx
@@ -15,6 +15,7 @@ import { useEffect, useState } from "react";
import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
+import { Trans, useTranslation } from "react-i18next";
type CameraInfoDialogProps = {
camera: CameraConfig;
@@ -26,6 +27,7 @@ export default function CameraInfoDialog({
showCameraInfoDialog,
setShowCameraInfoDialog,
}: CameraInfoDialogProps) {
+ const { t } = useTranslation(["views/system"]);
const [ffprobeInfo, setFfprobeInfo] = useState();
useEffect(() => {
@@ -39,15 +41,25 @@ export default function CameraInfoDialog({
if (res.status === 200) {
setFfprobeInfo(res.data);
} else {
- toast.error(`Unable to probe camera: ${res.statusText}`, {
- position: "top-center",
- });
+ toast.error(
+ t("cameras.toast.success.copyToClipboard", {
+ errorMessage: res.statusText,
+ }),
+ {
+ position: "top-center",
+ },
+ );
}
})
.catch((error) => {
- toast.error(`Unable to probe camera: ${error.response.data.message}`, {
- position: "top-center",
- });
+ toast.error(
+ t("cameras.toast.success.copyToClipboard", {
+ errorMessage: error.response.data.message,
+ }),
+ {
+ position: "top-center",
+ },
+ );
});
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -55,7 +67,7 @@ export default function CameraInfoDialog({
const onCopyFfprobe = async () => {
copy(JSON.stringify(ffprobeInfo));
- toast.success("Copied probe data to clipboard.");
+ toast.success(t("cameras.toast.success.copyToClipboard"));
};
function gcd(a: number, b: number): number {
@@ -72,11 +84,13 @@ export default function CameraInfoDialog({
- {camera.name.replaceAll("_", " ")} Camera Probe Info
+ {t("cameras.info.cameraProbeInfo", {
+ camera: camera.name.replaceAll("_", " "),
+ })}
- Stream data is obtained with ffprobe
.
+ cameras.info.streamDataFromFFPROBE
@@ -85,7 +99,9 @@ export default function CameraInfoDialog({
{ffprobeInfo.map((stream, idx) => (
- Stream {idx + 1}
+ {t("cameras.info.stream", {
+ idx: idx + 1,
+ })}
{stream.return_code == 0 ? (
@@ -93,10 +109,12 @@ export default function CameraInfoDialog({
{codec.width ? (
-
Video:
+
+ {t("cameras.info.video")}
+
- Codec:
+ {t("cameras.info.codec")}
{" "}
{codec.codec_long_name}
@@ -105,7 +123,7 @@ export default function CameraInfoDialog({
{codec.width && codec.height ? (
<>
- Resolution:{" "}
+ {t("cameras.info.resolution")}{" "}
{" "}
{codec.width}x{codec.height} (
@@ -119,7 +137,7 @@ export default function CameraInfoDialog({
>
) : (
- Resolution:{" "}
+ {t("cameras.info.resolution")}{" "}
Unknown
@@ -127,10 +145,10 @@ export default function CameraInfoDialog({
)}
- FPS:{" "}
+ {t("cameras.info.fps")}{" "}
{codec.avg_frame_rate == "0/0"
- ? "Unknown"
+ ? t("cameras.info.unknown")
: codec.avg_frame_rate}
@@ -140,7 +158,7 @@ export default function CameraInfoDialog({
Audio:
- Codec:{" "}
+ {t("cameras.info.codec")}{" "}
{codec.codec_long_name}
@@ -152,7 +170,11 @@ export default function CameraInfoDialog({
) : (
-
Error: {stream.stderr}
+
+ {t("cameras.info.error", {
+ error: stream.stderr,
+ })}
+
)}
@@ -161,7 +183,7 @@ export default function CameraInfoDialog({
) : (
-
Fetching Camera Data
+
{t("cameras.info.fetching")}
)}
@@ -169,10 +191,10 @@ export default function CameraInfoDialog({
onCopyFfprobe()}
>
- Copy
+ {t("button.copy", { ns: "common" })}
diff --git a/web/src/components/overlay/CreateUserDialog.tsx b/web/src/components/overlay/CreateUserDialog.tsx
index 89403c37f..8c886ea7e 100644
--- a/web/src/components/overlay/CreateUserDialog.tsx
+++ b/web/src/components/overlay/CreateUserDialog.tsx
@@ -22,6 +22,7 @@ import {
DialogHeader,
DialogTitle,
} from "../ui/dialog";
+
import {
Select,
SelectContent,
@@ -31,6 +32,7 @@ import {
} from "../ui/select";
import { Shield, User } from "lucide-react";
import { LuCheck, LuX } from "react-icons/lu";
+import { useTranslation } from "react-i18next";
type CreateUserOverlayProps = {
show: boolean;
@@ -43,22 +45,23 @@ export default function CreateUserDialog({
onCreate,
onCancel,
}: CreateUserOverlayProps) {
+ const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState
(false);
const formSchema = z
.object({
user: z
.string()
- .min(1, "Username is required")
+ .min(1, t("users.dialog.form.usernameIsRequired"))
.regex(/^[A-Za-z0-9._]+$/, {
- message: "Username may only include letters, numbers, . or _",
+ message: t("users.dialog.createUser.usernameOnlyInclude"),
}),
password: z.string().min(1, "Password is required"),
confirmPassword: z.string().min(1, "Please confirm your password"),
role: z.enum(["admin", "viewer"]),
})
.refine((data) => data.password === data.confirmPassword, {
- message: "Passwords don't match",
+ message: t("users.dialog.form.password.notMatch"),
path: ["confirmPassword"],
});
@@ -109,10 +112,9 @@ export default function CreateUserDialog({
- Create New User
+ {t("users.dialog.createUser.title")}
- Add a new user account and specify an role for access to areas of
- the Frigate UI.
+ {t("users.dialog.createUser.desc")}
@@ -126,17 +128,17 @@ export default function CreateUserDialog({
render={({ field }) => (
- Username
+ {t("users.dialog.form.user")}
- Only letters, numbers, periods and underscores allowed.
+ {t("users.dialog.form.user.desc")}
@@ -148,11 +150,11 @@ export default function CreateUserDialog({
render={({ field }) => (
- Password
+ {t("users.dialog.form.password")}
(
- Confirm Password
+ {t("users.dialog.form.password.confirm")}
- Passwords match
+ {t("users.dialog.form.password.match")}
>
) : (
<>
- Passwords don't match
+ {t("users.dialog.form.password.notMatch")}
>
)}
@@ -206,7 +210,9 @@ export default function CreateUserDialog({
name="role"
render={({ field }) => (
- Role
+
+ {t("role.title", { ns: "common" })}
+
- Admin
+ {t("role.admin", { ns: "common" })}
- Viewer
+ {t("role.viewer", { ns: "common" })}
- Admins have full access to all features in the Frigate UI.
- Viewers are limited to viewing cameras, review items, and
- historical footage in the UI.
+ {t("role.desc", { ns: "common" })}
@@ -252,16 +256,16 @@ export default function CreateUserDialog({
- Cancel
+ {t("button.cancel", { ns: "common" })}
- Saving...
+ {t("button.saving", { ns: "common" })}
) : (
- "Save"
+ t("button.save", { ns: "common" })
)}
diff --git a/web/src/components/overlay/DeleteUserDialog.tsx b/web/src/components/overlay/DeleteUserDialog.tsx
index e8dfb79c1..1e91196b4 100644
--- a/web/src/components/overlay/DeleteUserDialog.tsx
+++ b/web/src/components/overlay/DeleteUserDialog.tsx
@@ -1,10 +1,10 @@
+import { useTranslation } from "react-i18next";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
- DialogTitle,
} from "../ui/dialog";
import { DialogDescription } from "@radix-ui/react-dialog";
@@ -20,23 +20,22 @@ export default function DeleteUserDialog({
onDelete,
onCancel,
}: DeleteUserDialogProps) {
+ const { t } = useTranslation(["views/settings"]);
return (
- Delete User
+ {t("users.dialog.deleteUser.title")}
- This action cannot be undone. This will permanently delete the
- user account and remove all associated data.
+ {t("users.dialog.deleteUser.desc")}
- Are you sure you want to delete{" "}
- {username} ?
+ {t("users.dialog.deleteUser.warn", { username })}
@@ -45,19 +44,19 @@ export default function DeleteUserDialog({
- Cancel
+ {t("button.cancel", { ns: "common" })}
- Delete User
+ {t("button.delete", { ns: "common" })}
diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx
index 4f49abaf0..0f373057d 100644
--- a/web/src/components/overlay/ExportDialog.tsx
+++ b/web/src/components/overlay/ExportDialog.tsx
@@ -30,6 +30,7 @@ import { getUTCOffset } from "@/utils/dateUtil";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
+import { useTranslation } from "react-i18next";
const EXPORT_OPTIONS = [
"1",
@@ -64,16 +65,19 @@ export default function ExportDialog({
setMode,
setShowPreview,
}: ExportDialogProps) {
+ const { t } = useTranslation(["components/dialog"]);
const [name, setName] = useState("");
const onStartExport = useCallback(() => {
if (!range) {
- toast.error("No valid time range selected", { position: "top-center" });
+ toast.error(t("export.toast.error.noVaildTimeSelected"), {
+ position: "top-center",
+ });
return;
}
if (range.before < range.after) {
- toast.error("End time must be after start time", {
+ toast.error(t("export.toast.error.endTimeMustAfterStartTime"), {
position: "top-center",
});
return;
@@ -89,10 +93,9 @@ export default function ExportDialog({
)
.then((response) => {
if (response.status == 200) {
- toast.success(
- "Successfully started export. View the file in the /exports folder.",
- { position: "top-center" },
- );
+ toast.success(t("export.toast.success"), {
+ position: "top-center",
+ });
setName("");
setRange(undefined);
setMode("none");
@@ -103,11 +106,14 @@ export default function ExportDialog({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to start export: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("export.toast.error.failed", {
+ error: errorMessage,
+ }),
+ { position: "top-center" },
+ );
});
- }, [camera, name, range, setRange, setName, setMode]);
+ }, [camera, name, range, setRange, setName, setMode, t]);
const handleCancel = useCallback(() => {
setName("");
@@ -145,7 +151,7 @@ export default function ExportDialog({
{
const now = new Date(latestTime * 1000);
@@ -160,7 +166,11 @@ export default function ExportDialog({
}}
>
- {isDesktop && Export
}
+ {isDesktop && (
+
+ {t("menu.export", { ns: "common" })}
+
+ )}
("1");
const onSelectTime = useCallback(
@@ -256,7 +267,7 @@ export function ExportContent({
{isDesktop && (
<>
- Export
+ {t("menu.export")}
>
@@ -280,9 +291,11 @@ export function ExportContent({
{isNaN(parseInt(opt))
? opt == "timeline"
- ? "Select from Timeline"
- : `${opt}`
- : `Last ${opt > "1" ? `${opt} Hours` : "Hour"}`}
+ ? t("export.time.fromTimeline")
+ : t("export.time." + opt)
+ : t("export.time.lastHour", {
+ count: parseInt(opt),
+ })}
);
@@ -298,7 +311,7 @@ export function ExportContent({
setName(e.target.value)}
/>
@@ -310,11 +323,11 @@ export function ExportContent({
className={`cursor-pointer p-2 text-center ${isDesktop ? "" : "w-full"}`}
onClick={onCancel}
>
- Cancel
+ {t("button.cancel", { ns: "common" })}
{
@@ -328,7 +341,9 @@ export function ExportContent({
}
}}
>
- {selectedOption == "timeline" ? "Select" : "Export"}
+ {selectedOption == "timeline"
+ ? t("export.select")
+ : t("export.export")}
@@ -345,6 +360,7 @@ function CustomTimeSelector({
range,
setRange,
}: CustomTimeSelectorProps) {
+ const { t } = useTranslation(["components/dialog"]);
const { data: config } = useSWR
("config");
// times
@@ -388,14 +404,14 @@ function CustomTimeSelector({
const formattedStart = useFormattedTimestamp(
startTime,
config?.ui.time_format == "24hour"
- ? "%b %-d, %H:%M:%S"
- : "%b %-d, %I:%M:%S %p",
+ ? t("time.formattedTimestamp.24hour")
+ : t("time.formattedTimestamp"),
);
const formattedEnd = useFormattedTimestamp(
endTime,
config?.ui.time_format == "24hour"
- ? "%b %-d, %H:%M:%S"
- : "%b %-d, %I:%M:%S %p",
+ ? t("time.formattedTimestamp.24hour")
+ : t("time.formattedTimestamp"),
);
const startClock = useMemo(() => {
@@ -428,7 +444,7 @@ function CustomTimeSelector({
{
@@ -494,7 +510,7 @@ function CustomTimeSelector({
{
@@ -565,6 +581,7 @@ export function ExportPreviewDialog({
showPreview,
setShowPreview,
}: ExportPreviewDialogProps) {
+ const { t } = useTranslation(["components/dialog"]);
if (!range) {
return null;
}
@@ -582,9 +599,9 @@ export function ExportPreviewDialog({
)}
>
- Preview Export
+ {t("export.fromTimeline.previewExport")}
- Preview Export
+ {t("export.fromTimeline.previewExport")}
diff --git a/web/src/components/overlay/GPUInfoDialog.tsx b/web/src/components/overlay/GPUInfoDialog.tsx
index 3821579e2..6a1755c9d 100644
--- a/web/src/components/overlay/GPUInfoDialog.tsx
+++ b/web/src/components/overlay/GPUInfoDialog.tsx
@@ -11,6 +11,7 @@ import { GpuInfo, Nvinfo, Vainfo } from "@/types/stats";
import { Button } from "../ui/button";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
+import { useTranslation } from "react-i18next";
type GPUInfoDialogProps = {
showGpuInfo: boolean;
@@ -22,6 +23,8 @@ export default function GPUInfoDialog({
gpuType,
setShowGpuInfo,
}: GPUInfoDialogProps) {
+ const { t } = useTranslation(["views/system"]);
+
const { data: vainfo } = useSWR(
showGpuInfo && gpuType == "vainfo" ? "vainfo" : null,
);
@@ -35,7 +38,7 @@ export default function GPUInfoDialog({
.replace(/\\t/g, "\t")
.replace(/\\n/g, "\n"),
);
- toast.success("Copied GPU info to clipboard.");
+ toast.success(t("general.hardwareInfo.gpuInfo.toast.success"));
};
if (gpuType == "vainfo") {
@@ -43,13 +46,23 @@ export default function GPUInfoDialog({
- Vainfo Output
+
+ {t("general.hardwareInfo.gpuInfo.vainfoOutput.title")}
+
{vainfo ? (
-
Return Code: {vainfo.return_code}
+
+ {t("general.hardwareInfo.gpuInfo.vainfoOutput.returnCode", {
+ code: vainfo.return_code,
+ })}
+
-
Process {vainfo.return_code == 0 ? "Output" : "Error"}:
+
+ {vainfo.return_code == 0
+ ? t("general.hardwareInfo.gpuInfo.vainfoOutput.processOutput")
+ : t("general.hardwareInfo.gpuInfo.vainfoOutput.processError")}
+
{vainfo.return_code == 0 ? vainfo.stdout : vainfo.stderr}
@@ -60,17 +73,17 @@ export default function GPUInfoDialog({
)}
setShowGpuInfo(false)}
>
- Close
+ {t("button.close", { ns: "common" })}
onCopyInfo()}
>
- Copy
+ {t("button.copy", { ns: "common" })}
@@ -81,34 +94,52 @@ export default function GPUInfoDialog({
- Nvidia SMI Output
+
+ {t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.title")}
+
{nvinfo ? (
-
Name: {nvinfo["0"].name}
+
+ {t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", {
+ name: nvinfo["0"].name,
+ })}
+
-
Driver: {nvinfo["0"].driver}
+
+ {t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", {
+ name: nvinfo["0"].driver,
+ })}
+
-
Cuda Compute Capability: {nvinfo["0"].cuda_compute}
+
+ {t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", {
+ name: nvinfo["0"].cuda_compute,
+ })}
+
-
VBios Info: {nvinfo["0"].vbios}
+
+ {t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", {
+ name: nvinfo["0"].vbios,
+ })}
+
) : (
)}
setShowGpuInfo(false)}
>
- Close
+ {t("button.close", { ns: "common" })}
onCopyInfo()}
>
- Copy
+ {t("button.copy", { ns: "common" })}
diff --git a/web/src/components/overlay/MobileCameraDrawer.tsx b/web/src/components/overlay/MobileCameraDrawer.tsx
index c12bc0ab2..742de1242 100644
--- a/web/src/components/overlay/MobileCameraDrawer.tsx
+++ b/web/src/components/overlay/MobileCameraDrawer.tsx
@@ -3,6 +3,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button";
import { FaVideo } from "react-icons/fa";
import { isMobile } from "react-device-detect";
+import { useTranslation } from "react-i18next";
type MobileCameraDrawerProps = {
allCameras: string[];
@@ -14,6 +15,7 @@ export default function MobileCameraDrawer({
selected,
onSelectCamera,
}: MobileCameraDrawerProps) {
+ const { t } = useTranslation(["common"]);
const [cameraDrawer, setCameraDrawer] = useState(false);
if (!isMobile) {
@@ -25,7 +27,7 @@ export default function MobileCameraDrawer({
diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx
index 81b1eefe9..a94e6a9f1 100644
--- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx
+++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx
@@ -20,6 +20,8 @@ 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";
const DRAWER_FEATURES = ["export", "calendar", "filter"] as const;
@@ -68,6 +70,7 @@ export default function MobileReviewSettingsDrawer({
setMode,
setShowExportPreview,
}: MobileReviewSettingsDrawerProps) {
+ const { t } = useTranslation(["views/recording"]);
const [drawerMode, setDrawerMode] = useState("none");
// exports
@@ -75,12 +78,14 @@ export default function MobileReviewSettingsDrawer({
const [name, setName] = useState("");
const onStartExport = useCallback(() => {
if (!range) {
- toast.error("No valid time range selected", { position: "top-center" });
+ toast.error(t("toast.error.noValidTimeSelected"), {
+ position: "top-center",
+ });
return;
}
if (range.before < range.after) {
- toast.error("End time must be after start time", {
+ toast.error(t("toast.error.endTimeMustAfterStartTime"), {
position: "top-center",
});
return;
@@ -97,8 +102,10 @@ export default function MobileReviewSettingsDrawer({
.then((response) => {
if (response.status == 200) {
toast.success(
- "Successfully started export. View the file in the /exports folder.",
- { position: "top-center" },
+ t("export.toast.success", { ns: "components/dialog" }),
+ {
+ position: "top-center",
+ },
);
setName("");
setRange(undefined);
@@ -110,11 +117,17 @@ export default function MobileReviewSettingsDrawer({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to start export: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("export.toast.error.failed", {
+ ns: "components/dialog",
+ errorMessage,
+ }),
+ {
+ position: "top-center",
+ },
+ );
});
- }, [camera, name, range, setRange, setName, setMode]);
+ }, [camera, name, range, setRange, setName, setMode, t]);
// filters
@@ -136,40 +149,40 @@ export default function MobileReviewSettingsDrawer({
{features.includes("export") && (
{
setDrawerMode("export");
setMode("select");
}}
>
- Export
+ {t("export")}
)}
{features.includes("calendar") && (
setDrawerMode("calendar")}
>
- Calendar
+ {t("calendar")}
)}
{features.includes("filter") && (
setDrawerMode("filter")}
>
- Filter
+ {t("filter")}
)}
@@ -206,10 +219,10 @@ export default function MobileReviewSettingsDrawer({
className="absolute left-0 text-selected"
onClick={() => setDrawerMode("select")}
>
- Back
+ {t("button.back", { ns: "common" })}
- Calendar
+ {t("calendar")}
@@ -234,7 +247,7 @@ export default function MobileReviewSettingsDrawer({
{
onUpdateFilter({
...filter,
@@ -243,7 +256,7 @@ export default function MobileReviewSettingsDrawer({
});
}}
>
- Reset
+ {t("button.reset", { ns: "common" })}
@@ -256,10 +269,10 @@ export default function MobileReviewSettingsDrawer({
className="absolute left-0 text-selected"
onClick={() => setDrawerMode("select")}
>
- Back
+ {t("button.back", { ns: "common" })}
- Filter
+ {t("filter")}
(
currentRole,
);
@@ -41,27 +43,16 @@ export default function RoleChangeDialog({
- Change User Role
+ {t("users.dialog.changeRole.title")}
- Update permissions for{" "}
- {username}
+ {t("users.dialog.changeRole.desc", { username })}
-
Select the appropriate role for this user:
-
-
- • Admin: Full access to all
- features.
-
-
- • Viewer: Limited to Live
- dashboards, Review, Explore, and Exports only.
-
-
+
users.dialog.changeRole.roleInfo
- Admin
+ {t("role.admin")}
- Viewer
+ {t("role.viewer")}
@@ -95,20 +86,20 @@ export default function RoleChangeDialog({
- Cancel
+ {t("button.cancel", { ns: "common" })}
onSave(selectedRole)}
disabled={selectedRole === currentRole}
>
- Save
+ {t("button.save", { ns: "common" })}
diff --git a/web/src/components/overlay/SaveExportOverlay.tsx b/web/src/components/overlay/SaveExportOverlay.tsx
index 6bb899ed8..510dbafb0 100644
--- a/web/src/components/overlay/SaveExportOverlay.tsx
+++ b/web/src/components/overlay/SaveExportOverlay.tsx
@@ -2,6 +2,7 @@ import { LuVideo, LuX } from "react-icons/lu";
import { Button } from "../ui/button";
import { FaCompactDisc } from "react-icons/fa";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
type SaveExportOverlayProps = {
className: string;
@@ -17,6 +18,7 @@ export default function SaveExportOverlay({
onSave,
onCancel,
}: SaveExportOverlayProps) {
+ const { t } = useTranslation("components/dialog");
return (
- Cancel
+ {t("button.cancel", { ns: "common" })}
- Preview Export
+ {t("export.fromTimeline.previewExport")}
- Save Export
+ {t("export.fromTimeline.saveExport")}
diff --git a/web/src/components/overlay/SetPasswordDialog.tsx b/web/src/components/overlay/SetPasswordDialog.tsx
index 108b568d7..bb04e2690 100644
--- a/web/src/components/overlay/SetPasswordDialog.tsx
+++ b/web/src/components/overlay/SetPasswordDialog.tsx
@@ -11,8 +11,10 @@ import {
DialogHeader,
DialogTitle,
} from "../ui/dialog";
+
import { Label } from "../ui/label";
import { LuCheck, LuX } from "react-icons/lu";
+import { useTranslation } from "react-i18next";
type SetPasswordProps = {
show: boolean;
@@ -27,6 +29,7 @@ export default function SetPasswordDialog({
onCancel,
username,
}: SetPasswordProps) {
+ const { t } = useTranslation(["views/settings"]);
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordStrength, setPasswordStrength] = useState(0);
@@ -77,10 +80,13 @@ export default function SetPasswordDialog({
const getStrengthLabel = () => {
if (!password) return "";
- if (passwordStrength <= 1) return "Weak";
- if (passwordStrength === 2) return "Medium";
- if (passwordStrength === 3) return "Strong";
- return "Very Strong";
+ if (passwordStrength <= 1)
+ return t("users.dialog.form.password.strength.weak");
+ if (passwordStrength === 2)
+ return t("users.dialog.form.password.strength.medium");
+ if (passwordStrength === 3)
+ return t("users.dialog.form.password.strength.strong");
+ return t("users.dialog.form.password.strength.veryStrong");
};
const getStrengthColor = () => {
@@ -96,16 +102,23 @@ export default function SetPasswordDialog({
- {username ? `Update Password for ${username}` : "Set Password"}
+ {username
+ ? t("users.dialog.passwordSetting.updatePassword", {
+ username,
+ ns: "views/settings",
+ })
+ : t("users.dialog.passwordSetting.setPassword")}
- Create a strong password to secure this account.
+ {t("users.dialog.passwordSetting.desc")}
@@ -137,7 +150,9 @@ export default function SetPasswordDialog({
- Confirm Password
+
+ {t("users.dialog.form.password.confirm")}
+
{/* Password match indicator */}
@@ -156,12 +173,16 @@ export default function SetPasswordDialog({
{password === confirmPassword ? (
<>
- Passwords match
+
+ {t("users.dialog.form.password.match")}
+
>
) : (
<>
- Passwords don't match
+
+ {t("users.dialog.form.password.notMatch")}
+
>
)}
@@ -180,20 +201,20 @@ export default function SetPasswordDialog({
- Cancel
+ {t("button.cancel", { ns: "common" })}
- Save
+ {t("button.save", { ns: "common" })}
diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx
index df529c0dc..d9e08db65 100644
--- a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx
+++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx
@@ -26,6 +26,7 @@ import { Button } from "@/components/ui/button";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
+import { Trans, useTranslation } from "react-i18next";
type AnnotationSettingsPaneProps = {
event: Event;
@@ -41,6 +42,8 @@ export function AnnotationSettingsPane({
annotationOffset,
setAnnotationOffset,
}: AnnotationSettingsPaneProps) {
+ const { t } = useTranslation(["views/explore"]);
+
const { data: config, mutate: updateConfig } =
useSWR("config");
@@ -81,9 +84,15 @@ export function AnnotationSettingsPane({
);
updateConfig();
} else {
- toast.error(`Failed to save config changes: ${res.statusText}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage: res.statusText,
+ ns: "common",
+ }),
+ {
+ position: "top-center",
+ },
+ );
}
})
.catch((error) => {
@@ -91,7 +100,7 @@ export function AnnotationSettingsPane({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to save config changes: ${errorMessage}`, {
+ toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
})
@@ -99,7 +108,7 @@ export function AnnotationSettingsPane({
setIsLoading(false);
});
},
- [updateConfig, config, event],
+ [updateConfig, config, event, t],
);
function onSubmit(values: z.infer) {
@@ -126,7 +135,7 @@ export function AnnotationSettingsPane({
return (
- Annotation Settings
+ {t("objectLifecycle.annotationSettings.title")}
@@ -136,11 +145,11 @@ export function AnnotationSettingsPane({
onCheckedChange={setShowZones}
/>
- Show All Zones
+ {t("objectLifecycle.annotationSettings.showAllZones")}
- Always show zones on frames where objects have entered a zone.
+ {t("objectLifecycle.annotationSettings.showAllZones.desc")}
@@ -154,17 +163,16 @@ export function AnnotationSettingsPane({
name="annotationOffset"
render={({ field }) => (
- Annotation Offset
+
+ {t("objectLifecycle.annotationSettings.offset.label")}
+
- 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
annotation_offset
{" "}
- field can be used to adjust this.
+
+ objectLifecycle.annotationSettings.offset.desc
+
- Read the documentation{" "}
+ {t(
+ "objectLifecycle.annotationSettings.offset.documentation",
+ )}
@@ -187,16 +197,11 @@ export function AnnotationSettingsPane({
/>
- Milliseconds to offset detect annotations by.{" "}
- Default: 0
+ {t(
+ "objectLifecycle.annotationSettings.offset.millisecondsToOffset",
+ )}
- TIP: Imagine there is an event clip with a person
- walking from left to right. If the event timeline
- bounding box is consistently to the left of the person
- then the value should be decreased. Similarly, if a
- person is walking from left to right and the bounding
- box is consistently ahead of the person then the value
- should be increased.
+ {t("objectLifecycle.annotationSettings.offset.tips")}
@@ -210,14 +215,14 @@ export function AnnotationSettingsPane({
- Apply
+ {t("button.apply", { ns: "common" })}
- Saving...
+ {t("button.saving", { ns: "common" })}
) : (
- "Save"
+ t("button.save", { ns: "common" })
)}
diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx
index da9bd61b0..1809ff56f 100644
--- a/web/src/components/overlay/detail/ObjectLifecycle.tsx
+++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx
@@ -54,6 +54,7 @@ import { useNavigate } from "react-router-dom";
import { ObjectPath } from "./ObjectPath";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { IoPlayCircleOutline } from "react-icons/io5";
+import { useTranslation } from "react-i18next";
type ObjectLifecycleProps = {
className?: string;
@@ -68,6 +69,8 @@ export default function ObjectLifecycle({
fullscreen = false,
setPane,
}: ObjectLifecycleProps) {
+ const { t } = useTranslation(["views/explore"]);
+
const { data: eventSequence } = useSWR
([
"timeline",
{
@@ -334,12 +337,16 @@ export default function ObjectLifecycle({
setPane("overview")}
>
- {isDesktop && Back
}
+ {isDesktop && (
+
+ {t("button.back", { ns: "common" })}
+
+ )}
)}
@@ -361,7 +368,7 @@ export default function ObjectLifecycle({
- No image found for this timestamp.
+ {t("objectLifecycle.noImageFound")}
)}
@@ -464,11 +471,13 @@ export default function ObjectLifecycle({
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={() =>
navigate(
- `/settings?page=masks%20/%20zones&camera=${event.camera}&object_mask=${eventSequence?.[current].data.box}`,
+ `/settings?page=masksAndZones&camera=${event.camera}&object_mask=${eventSequence?.[current].data.box}`,
)
}
>
- Create Object Mask
+
+ {t("objectLifecycle.createObjectMask")}
+
@@ -477,7 +486,7 @@ export default function ObjectLifecycle({
-
Object Lifecycle
+
{t("objectLifecycle.title")}
@@ -485,7 +494,7 @@ export default function ObjectLifecycle({
- Adjust annotation settings
+
+ {t("objectLifecycle.adjustAnnotationSettings")}
+
- Scroll to view the significant moments of this object's lifecycle.
+ {t("objectLifecycle.scrollViewTips")}
{current + 1} of {eventSequence.length}
@@ -509,7 +520,7 @@ export default function ObjectLifecycle({
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
- Bounding box positions will be inaccurate for autotracking cameras.
+ {t("objectLifecycle.autoTrackingTips")}
)}
{showControls && (
@@ -559,8 +570,8 @@ export default function ObjectLifecycle({
timezone: config.ui.timezone,
strftime_fmt:
config.ui.time_format == "24hour"
- ? "%d %b %H:%M:%S"
- : "%m/%d %I:%M:%S%P",
+ ? t("time.formattedTimestamp2.24hour")
+ : t("time.formattedTimestamp2"),
time_style: "medium",
date_style: "medium",
})}
diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx
index 2570fd033..f27543484 100644
--- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx
+++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx
@@ -42,6 +42,7 @@ import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { LuSearch } from "react-icons/lu";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
+import { Trans, useTranslation } from "react-i18next";
type ReviewDetailDialogProps = {
review?: ReviewSegment;
@@ -51,6 +52,7 @@ export default function ReviewDetailDialog({
review,
setReview,
}: ReviewDetailDialogProps) {
+ const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR
("config", {
revalidateOnFocus: false,
});
@@ -95,8 +97,8 @@ export default function ReviewDetailDialog({
const formattedDate = useFormattedTimestamp(
review?.start_time ?? 0,
config?.ui.time_format == "24hour"
- ? "%b %-d %Y, %H:%M"
- : "%b %-d %Y, %I:%M %p",
+ ? t("time.formattedTimestampWithYear.24hour", { ns: "common" })
+ : t("time.formattedTimestampWithYear", { ns: "common" }),
config?.ui.timezone,
);
@@ -177,8 +179,10 @@ export default function ReviewDetailDialog({
{pane == "overview" && (
- Review Item Details
- Review item details
+ {t("details.item.title")}
+
+ {t("details.item.desc")}
+
shareOrCopy(`${baseUrl}review?id=${review.id}`)
@@ -199,7 +203,9 @@ export default function ReviewDetailDialog({
- Share this review item
+
+ {t("details.item.button.share")}
+
@@ -211,7 +217,9 @@ export default function ReviewDetailDialog({
/>
- Download
+
+ {t("button.download", { ns: "common" })}
+
@@ -222,19 +230,25 @@ export default function ReviewDetailDialog({
-
Camera
+
+ {t("details.camera")}
+
{review.camera.replaceAll("_", " ")}
-
Timestamp
+
+ {t("details.timestamp")}
+
{formattedDate}
-
Objects
+
+ {t("details.objects")}
+
{events?.map((event) => {
return (
@@ -260,7 +274,9 @@ export default function ReviewDetailDialog({
- View in Explore
+
+ {t("details.item.button.viewInExplore")}
+
@@ -270,7 +286,9 @@ export default function ReviewDetailDialog({
{review.data.zones.length > 0 && (
-
Zones
+
+ {t("details.zones")}
+
{review.data.zones.map((zone) => {
return (
@@ -294,18 +312,23 @@ export default function ReviewDetailDialog({
(events?.length ?? 0) -
(review?.data.detections.length ?? 0),
);
- const objectLabel =
- detectedCount === 1 ? "object was" : "objects were";
- return `${detectedCount} unavailable ${objectLabel} detected and included in this review item.`;
- })()}{" "}
- Those objects either did not qualify as an alert or detection
- or have already been cleaned up/deleted.
+ return t("details.item.tips.mismatch", {
+ count: detectedCount,
+ });
+ })()}
{missingObjects.length > 0 && (
- Adjust your configuration if you want Frigate to save
- tracked objects for the following labels:{" "}
- {missingObjects.join(", ")}
+ t(x, { ns: "objects" }))
+ .join(", "),
+ }}
+ >
+ details.item.tips.hasMissingObjects
+
)}
@@ -348,6 +371,8 @@ function EventItem({
setSelectedEvent,
setUpload,
}: EventItemProps) {
+ const { t } = useTranslation(["views/explore"]);
+
const { data: config } = useSWR
("config", {
revalidateOnFocus: false,
});
@@ -417,7 +442,9 @@ function EventItem({
- Download
+
+ {t("button.download", { ns: "common" })}
+
{event.has_snapshot &&
@@ -435,7 +462,9 @@ function EventItem({
- Submit to Frigate+
+
+ {t("itemMenu.submitToPlus.label")}
+
)}
@@ -452,7 +481,9 @@ function EventItem({
- View Object Lifecycle
+
+ {t("itemMenu.viewObjectLifecycle.label")}
+
)}
@@ -470,7 +501,9 @@ function EventItem({
- Find Similar
+
+ {t("itemMenu.findSimilar.label")}
+
)}
diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx
index ed472c742..9bc384861 100644
--- a/web/src/components/overlay/detail/SearchDetailDialog.tsx
+++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx
@@ -73,12 +73,13 @@ import { LuInfo } 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";
const SEARCH_TABS = [
"details",
"snapshot",
"video",
- "object lifecycle",
+ "object_lifecycle",
] as const;
export type SearchTab = (typeof SEARCH_TABS)[number];
@@ -98,6 +99,7 @@ export default function SearchDetailDialog({
setSimilarity,
setInputFocused,
}: SearchDetailDialogProps) {
+ const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR
("config", {
revalidateOnFocus: false,
});
@@ -152,7 +154,7 @@ export default function SearchDetailDialog({
}
if (search.data.type != "object" || !search.has_clip) {
- const index = views.indexOf("object lifecycle");
+ const index = views.indexOf("object_lifecycle");
views.splice(index, 1);
}
@@ -192,8 +194,8 @@ export default function SearchDetailDialog({
)}
>
- Tracked Object Details
- Tracked object details
+ {t("trackedObjectDetails")}
+ {t("details")}
}
{item == "snapshot" && }
{item == "video" && }
- {item == "object lifecycle" && (
+ {item == "object_lifecycle" && (
)}
- {item}
+ {t("type.{item}")}
))}
@@ -254,7 +256,7 @@ export default function SearchDetailDialog({
/>
)}
{page == "video" && }
- {page == "object lifecycle" && (
+ {page == "object_lifecycle" && (
{
if (resp.status == 200) {
- toast.success("Successfully saved description", {
+ toast.success(t("details.tips.descriptionSaved"), {
position: "top-center",
});
}
@@ -416,12 +420,17 @@ function ObjectDetailsTab({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to update the description: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("details.tips.saveDescriptionFailed", {
+ errorMessage,
+ }),
+ {
+ position: "top-center",
+ },
+ );
setDesc(search.data.description);
});
- }, [desc, search, mutate]);
+ }, [desc, search, mutate, t]);
const regenerateDescription = useCallback(
(source: "snapshot" | "thumbnails") => {
@@ -434,7 +443,12 @@ function ObjectDetailsTab({
.then((resp) => {
if (resp.status == 200) {
toast.success(
- `A new description has been requested from ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")}. Depending on the speed of your provider, the new description may take some time to regenerate.`,
+ t("details.item.toast.success.regenerate", {
+ provider: capitalizeAll(
+ config?.genai.provider.replaceAll("_", " ") ??
+ t("generativeAI"),
+ ),
+ }),
{
position: "top-center",
duration: 7000,
@@ -448,12 +462,18 @@ function ObjectDetailsTab({
error.response?.data?.detail ||
"Unknown error";
toast.error(
- `Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${errorMessage}`,
+ t("details.item.toast.error.regenerate", {
+ provider: capitalizeAll(
+ config?.genai.provider.replaceAll("_", " ") ??
+ t("generativeAI"),
+ ),
+ errorMessage,
+ }),
{ position: "top-center" },
);
});
},
- [search, config],
+ [search, config, t],
);
const handleSubLabelSave = useCallback(
@@ -472,7 +492,7 @@ function ObjectDetailsTab({
})
.then((response) => {
if (response.status === 200) {
- toast.success("Successfully updated sub label.", {
+ toast.success(t("details.item.toast.success.updatedSublabel"), {
position: "top-center",
});
@@ -520,12 +540,17 @@ function ObjectDetailsTab({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to update sub label: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("details.item.toast.error.updatedSublabelFailed", {
+ errorMessage,
+ }),
+ {
+ position: "top-center",
+ },
+ );
});
},
- [search, apiHost, mutate, setSearch],
+ [search, apiHost, mutate, setSearch, t],
);
return (
@@ -533,10 +558,10 @@ function ObjectDetailsTab({
-
Label
+
{t("details.label")}
{getIconForLabel(search.label, "size-4 text-primary")}
- {search.label}
+ {t("{search.label}", { ns: "objects" })}
{search.sub_label && ` (${search.sub_label})`}
@@ -550,7 +575,7 @@ function ObjectDetailsTab({
- Edit sub label
+ {t("details.editSubLable")}
@@ -572,7 +597,7 @@ function ObjectDetailsTab({
- Top Score
+ {t("details.topScore")}
@@ -581,9 +606,7 @@ function ObjectDetailsTab({
- 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.
+ {t("details.topScore.info")}
@@ -594,12 +617,16 @@ function ObjectDetailsTab({
{averageEstimatedSpeed && (
-
Estimated Speed
+
+ {t("details.estimatedSpeed")}
+
{averageEstimatedSpeed && (
{averageEstimatedSpeed}{" "}
- {config?.ui.unit_system == "imperial" ? "mph" : "kph"}{" "}
+ {config?.ui.unit_system == "imperial"
+ ? t("unit.speed.mph", { ns: "common" })
+ : t("unit.speed.kph", { ns: "common" })}{" "}
{velocityAngle != undefined && (
)}
-
Camera
+
{t("details.camera")}
{search.camera.replaceAll("_", " ")}
-
Timestamp
+
+ {t("details.timestamp")}
+
{formattedDate}
@@ -642,7 +671,7 @@ function ObjectDetailsTab({
/>
{config?.semantic_search.enabled && search.data.type == "object" && (
{
setSearch(undefined);
@@ -651,7 +680,7 @@ function ObjectDetailsTab({
}
}}
>
- Find Similar
+ {t("itemMenu.findSimilar.label")}
)}
@@ -673,18 +702,15 @@ function ObjectDetailsTab({
-
- Frigate will not request a description from your Generative AI
- provider until the tracked object's lifecycle has ended.
-
+
{t("details.description.aiTips")}
>
) : (
<>
-
Description
+
- Objects in locations you want to avoid are not false
- positives. Submitting them as false positives will
- confuse the model.
+ {t("explore.submitToPlus.desc")}
@@ -881,28 +914,36 @@ export function ObjectSnapshotTab({
<>
{
setState("uploading");
onSubmitToPlus(false);
}}
>
- This is{" "}
- {/^[aeiou]/i.test(search?.label || "") ? "an" : "a"}{" "}
- {search?.label}
+ {/^[aeiou]/i.test(search?.label || "")
+ ? t("explore.plus.review.true_other", {
+ label: search?.label,
+ })
+ : t("explore.plus.review.true_one", {
+ label: search?.label,
+ })}
{
setState("uploading");
onSubmitToPlus(true);
}}
>
- This is not{" "}
- {/^[aeiou]/i.test(search?.label || "") ? "an" : "a"}{" "}
- {search?.label}
+ {/^[aeiou]/i.test(search?.label || "")
+ ? t("explore.plus.review.false_other", {
+ label: search?.label,
+ })
+ : t("explore.plus.review.false_one", {
+ label: search?.label,
+ })}
>
)}
@@ -910,7 +951,7 @@ export function ObjectSnapshotTab({
{state == "submitted" && (
- Submitted
+ {t("explore.plus.review.state.submitted")}
)}
@@ -929,6 +970,7 @@ type VideoTabProps = {
};
export function VideoTab({ search }: VideoTabProps) {
+ const { t } = useTranslation(["views/explore"]);
const navigate = useNavigate();
const { data: reviewItem } = useSWR
([
`review/event/${search.id}`,
@@ -963,7 +1005,9 @@ export function VideoTab({ search }: VideoTabProps) {
- View in History
+
+ {t("itemMenu.viewInHistory.label")}
+
@@ -978,7 +1022,9 @@ export function VideoTab({ search }: VideoTabProps) {
- Download
+
+ {t("button.download", { ns: "common" })}
+
diff --git a/web/src/components/overlay/dialog/RestartDialog.tsx b/web/src/components/overlay/dialog/RestartDialog.tsx
index 8e1a5c129..a8085c92c 100644
--- a/web/src/components/overlay/dialog/RestartDialog.tsx
+++ b/web/src/components/overlay/dialog/RestartDialog.tsx
@@ -19,6 +19,8 @@ import { Button } from "@/components/ui/button";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { baseUrl } from "@/api/baseUrl";
+import { useTranslation } from "react-i18next";
+
type RestartDialogProps = {
isOpen: boolean;
onClose: () => void;
@@ -30,6 +32,7 @@ export default function RestartDialog({
onClose,
onRestart,
}: RestartDialogProps) {
+ const { t } = useTranslation("components/dialog");
const [restartDialogOpen, setRestartDialogOpen] = useState(isOpen);
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
const [countdown, setCountdown] = useState(60);
@@ -78,14 +81,14 @@ export default function RestartDialog({
>
-
- Are you sure you want to restart Frigate?
-
+ {t("restart.title")}
- Cancel
+
+ {t("button.cancel", { ns: "common" })}
+
- Restart
+ {t("restart.button")}
@@ -100,19 +103,23 @@ export default function RestartDialog({
- Frigate is Restarting
+ {t("restart.restarting.title")}
- This page will reload in {countdown} seconds.
+
+ {t("restart.restarting.content", {
+ countdown,
+ })}
+
- Force Reload Now
+ {t("restart.restarting.button")}
diff --git a/web/src/components/overlay/dialog/SearchFilterDialog.tsx b/web/src/components/overlay/dialog/SearchFilterDialog.tsx
index 7fadc1dac..bf77462b1 100644
--- a/web/src/components/overlay/dialog/SearchFilterDialog.tsx
+++ b/web/src/components/overlay/dialog/SearchFilterDialog.tsx
@@ -33,6 +33,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { Trans, useTranslation } from "react-i18next";
import {
Command,
CommandEmpty,
@@ -60,7 +61,7 @@ export default function SearchFilterDialog({
onUpdateFilter,
}: SearchFilterDialogProps) {
// data
-
+ const { t } = useTranslation(["components/filter"]);
const [currentFilter, setCurrentFilter] = useState(filter ?? {});
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
@@ -93,7 +94,7 @@ export default function SearchFilterDialog({
const trigger = (
@@ -102,7 +103,7 @@ export default function SearchFilterDialog({
moreFiltersSelected ? "text-white" : "text-secondary-foreground",
)}
/>
- More Filters
+ {t("more")}
);
const content = (
@@ -184,7 +185,7 @@ export default function SearchFilterDialog({
{
if (currentFilter != filter) {
onUpdateFilter(currentFilter);
@@ -193,10 +194,10 @@ export default function SearchFilterDialog({
setOpen(false);
}}
>
- Apply
+ {t("button.apply", { ns: "common" })}
{
setCurrentFilter((prevFilter) => ({
...prevFilter,
@@ -214,7 +215,7 @@ export default function SearchFilterDialog({
}));
}}
>
- Reset
+ {t("button.reset", { ns: "common" })}
@@ -250,6 +251,7 @@ function TimeRangeFilterContent({
timeRange,
updateTimeRange,
}: TimeRangeFilterContentProps) {
+ const { t } = useTranslation(["components/filter"]);
const [startOpen, setStartOpen] = useState(false);
const [endOpen, setEndOpen] = useState(false);
@@ -293,7 +295,7 @@ function TimeRangeFilterContent({
return (
-
Time Range
+
{t("timeRange")}
{
@@ -344,7 +348,9 @@ function TimeRangeFilterContent({
{
@@ -387,11 +393,12 @@ export function ZoneFilterContent({
zones,
updateZones,
}: ZoneFilterContentProps) {
+ const { t } = useTranslation(["components/filter"]);
return (
<>
-
Zones
+
{t("zones.label")}
{allZones && (
<>
@@ -399,7 +406,7 @@ export function ZoneFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allZones"
>
- All Zones
+ {t("zones.all")}
- Sub Labels
+ {t("subLabels.label")}
- All Sub Labels
+ {t("subLabels.all")}
- Score
+ {t("score")}
- Estimated Speed ({config?.ui.unit_system == "metric" ? "kph" : "mph"})
+ {t("estimatedSpeed", {
+ unit:
+ config?.ui.unit_system == "metric"
+ ? t("unit.speed.kph", { ns: "common" })
+ : t("unit.speed.mph", { ns: "common" }),
+ })}
-
Features
+
{t("features.label")}
@@ -679,7 +695,7 @@ export function SnapshotClipFilterContent({
htmlFor="snapshot-filter"
className="cursor-pointer text-sm font-medium leading-none"
>
- Has a snapshot
+ {t("features.hasSnapshot")}
- Yes
+ {t("button.yes", { ns: "common" })}
- No
+ {t("button.no", { ns: "common" })}
@@ -741,12 +757,9 @@ export function SnapshotClipFilterContent({
side="left"
sideOffset={5}
>
- You must first filter on tracked objects that have a
- snapshot.
-
-
- Tracked objects without a snapshot cannot be submitted to
- Frigate+.
+
+ features.submittedToFrigatePlus.tips
+
)}
@@ -755,7 +768,7 @@ export function SnapshotClipFilterContent({
htmlFor="plus-filter"
className="cursor-pointer text-sm font-medium leading-none"
>
- Submitted to Frigate+
+ {t("features.submittedToFrigatePlus.label")}
- Yes
+ {t("button.yes", { ns: "common" })}
- No
+ {t("button.no", { ns: "common" })}
@@ -817,7 +830,7 @@ export function SnapshotClipFilterContent({
htmlFor="clip-filter"
className="cursor-pointer text-sm font-medium leading-none"
>
- Has a video clip
+ {t("features.hasVideoClip")}
- Yes
+ {t("button.yes", { ns: "common" })}
- No
+ {t("button.no", { ns: "common" })}
@@ -863,6 +876,8 @@ export function RecognizedLicensePlatesFilterContent({
recognizedLicensePlates,
setRecognizedLicensePlates,
}: RecognizedLicensePlatesFilterContentProps) {
+ const { t } = useTranslation(["components/filter"]);
+
const { data: allRecognizedLicensePlates, error } = useSWR
(
"recognized_license_plates",
{
@@ -911,26 +926,28 @@ export function RecognizedLicensePlatesFilterContent({
return (
-
Recognized License Plates
+
{t("recognizedLicensePlates.title")}
{error ? (
- Failed to load recognized license plates.
+ {t("recognizedLicensePlates.loadFailed")}
) : !allRecognizedLicensePlates ? (
- Loading recognized license plates...
+ {t("recognizedLicensePlates.loading")}
) : (
<>
{filteredRecognizedLicensePlates.length === 0 && inputValue && (
- No license plates found.
+
+ {t("recognizedLicensePlates.noLicensePlatesFound")}
+
)}
{filteredRecognizedLicensePlates.map((plate) => (
)}
- Select one or more plates from the list.
+ {t("recognizedLicensePlates.selectPlatesFromList")}
);
diff --git a/web/src/components/overlay/dialog/TextEntryDialog.tsx b/web/src/components/overlay/dialog/TextEntryDialog.tsx
index c11a84ae7..a25c023ea 100644
--- a/web/src/components/overlay/dialog/TextEntryDialog.tsx
+++ b/web/src/components/overlay/dialog/TextEntryDialog.tsx
@@ -12,6 +12,8 @@ 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 = {
@@ -37,6 +39,8 @@ export default function TextEntryDialog({
text: z.string(),
});
+ const { t } = useTranslation("components/dialog");
+
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: { text: defaultValue },
@@ -87,10 +91,10 @@ export default function TextEntryDialog({
/>
setOpen(false)}>
- Cancel
+ {t("button.cancel", { ns: "common" })}
- Save
+ {t("button.save", { ns: "common" })}
diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx
index 6c04bb6dd..1350a70b1 100644
--- a/web/src/components/player/HlsVideoPlayer.tsx
+++ b/web/src/components/player/HlsVideoPlayer.tsx
@@ -18,6 +18,7 @@ import { useOverlayState } from "@/hooks/use-overlay-state";
import { usePersistence } from "@/hooks/use-persistence";
import { cn } from "@/lib/utils";
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
+import { useTranslation } from "react-i18next";
// Android native hls does not seek correctly
const USE_NATIVE_HLS = !isAndroid;
@@ -63,6 +64,7 @@ export default function HlsVideoPlayer({
toggleFullscreen,
onError,
}: HlsVideoPlayerProps) {
+ const { t } = useTranslation("components/player");
const { data: config } = useSWR("config");
// playback
@@ -236,11 +238,11 @@ export default function HlsVideoPlayer({
const resp = await onUploadFrame(videoRef.current.currentTime);
if (resp && resp.status == 200) {
- toast.success("Successfully submitted frame to Frigate+", {
+ toast.success(t("toast.success.submittedFrigatePlus"), {
position: "top-center",
});
} else {
- toast.success("Failed to submit frame to Frigate+", {
+ toast.success(t("toast.error.submitFrigatePlusFailed"), {
position: "top-center",
});
}
diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx
index c34f4c94c..b73e7991c 100644
--- a/web/src/components/player/LivePlayer.tsx
+++ b/web/src/components/player/LivePlayer.tsx
@@ -23,6 +23,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import { baseUrl } from "@/api/baseUrl";
import { PlayerStats } from "./PlayerStats";
import { LuVideoOff } from "react-icons/lu";
+import { Trans, useTranslation } from "react-i18next";
type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void;
@@ -71,6 +72,8 @@ export default function LivePlayer({
onError,
onResetLiveMode,
}: LivePlayerProps) {
+ const { t } = useTranslation(["components/player"]);
+
const internalContainerRef = useRef(null);
// stats
@@ -272,7 +275,7 @@ export default function LivePlayer({
} else {
player = (
- iOS 17.1 or greater is required for this live stream type.
+ {t("livePlayerRequiredIOSVersion")}
);
}
@@ -400,12 +403,17 @@ export default function LivePlayer({
{offline && !showStillWithoutActivity && cameraEnabled && (
-
Stream offline
+
{t("streamOffline.title")}
- No frames have been received on the{" "}
- {capitalizeFirstLetter(cameraConfig.name)} detect
{" "}
- stream, check error logs
+
+ streamOffline.desc
+
@@ -416,7 +424,7 @@ export default function LivePlayer({
- Camera is disabled
+ {t("cameraDisabled")}
diff --git a/web/src/components/player/PlayerStats.tsx b/web/src/components/player/PlayerStats.tsx
index 50c49b846..4289b1257 100644
--- a/web/src/components/player/PlayerStats.tsx
+++ b/web/src/components/player/PlayerStats.tsx
@@ -1,5 +1,6 @@
import { cn } from "@/lib/utils";
import { PlayerStatsType } from "@/types/live";
+import { useTranslation } from "react-i18next";
type PlayerStatsProps = {
stats: PlayerStatsType;
@@ -7,45 +8,46 @@ type PlayerStatsProps = {
};
export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
+ const { t } = useTranslation(["components/player"]);
const fullStatsContent = (
<>
- Stream Type: {" "}
+ {t("stats.streamType")} {" "}
{stats.streamType}
- Bandwidth: {" "}
+ {t("stats.bandwidth")} {" "}
{stats.bandwidth.toFixed(2)} kbps
{stats.latency != undefined && (
- Latency: {" "}
+ {t("stats.latency")} {" "}
2 ? "text-danger" : ""}`}
>
- {stats.latency.toFixed(2)} seconds
+ {t("stats.latency.value", { secounds: stats.latency.toFixed(2) })}
)}
- Total Frames: {" "}
+ {t("stats.totalFrames")} {" "}
{stats.totalFrames}
{stats.droppedFrames != undefined && (
- Dropped Frames: {" "}
+ {t("stats.droppedFrames")} {" "}
{stats.droppedFrames}
)}
{stats.decodedFrames != undefined && (
- Decoded Frames: {" "}
+ {t("stats.decodedFrames")} {" "}
{stats.decodedFrames}
)}
{stats.droppedFrameRate != undefined && (
- Dropped Frame Rate: {" "}
+ {t("stats.droppedFrameRate")} {" "}
{stats.droppedFrameRate.toFixed(2)}%
@@ -57,27 +59,35 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
const minimalStatsContent = (
- Type
+ {t("stats.streamType.short")}
{stats.streamType}
- Bandwidth {" "}
+ {t("stats.bandwidth.short")} {" "}
{stats.bandwidth.toFixed(2)} kbps
{stats.latency != undefined && (
- Latency
+ {t("stats.latency.short")}
= 2 ? "text-danger" : ""}`}
>
- {stats.latency.toFixed(2)} sec
+ {t("stats.latency.short.value", {
+ secounds: stats.latency.toFixed(2),
+ })}
)}
{stats.droppedFrames != undefined && (
- Dropped
- {stats.droppedFrames} frames
+
+ {t("stats.droppedFrames.short")}
+
+
+ {t("stats.droppedFrames.short.value", {
+ droppedFrames: stats.droppedFrames,
+ })}
+
)}
diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx
index b233c6ad4..d103ecb36 100644
--- a/web/src/components/player/PreviewPlayer.tsx
+++ b/web/src/components/player/PreviewPlayer.tsx
@@ -20,6 +20,7 @@ import {
getPreviewForTimeRange,
usePreviewForTimeRange,
} from "@/hooks/use-camera-previews";
+import { useTranslation } from "react-i18next";
type PreviewPlayerProps = {
className?: string;
@@ -42,6 +43,7 @@ export default function PreviewPlayer({
onControllerReady,
onClick,
}: PreviewPlayerProps) {
+ const { t } = useTranslation(["components/player"]);
const [currentHourFrame, setCurrentHourFrame] = useState();
const currentPreview = usePreviewForTimeRange(
cameraPreviews,
@@ -88,7 +90,7 @@ export default function PreviewPlayer({
className,
)}
>
- No Preview Found
+ {t("noPreviewFound")}
);
}
@@ -133,6 +135,7 @@ function PreviewVideoPlayer({
onClick,
setCurrentHourFrame,
}: PreviewVideoPlayerProps) {
+ const { t } = useTranslation(["components/player"]);
const { data: config } = useSWR
("config");
// controlling playback
@@ -324,7 +327,7 @@ function PreviewVideoPlayer({
{cameraPreviews && !currentPreview && (
- No Preview Found for {camera.replaceAll("_", " ")}
+ {t("noPreviewFoundFor", { camera: camera.replaceAll("_", " ") })}
)}
{firstLoad && }
@@ -444,6 +447,8 @@ function PreviewFramesPlayer({
onControllerReady,
onClick,
}: PreviewFramesPlayerProps) {
+ const { t } = useTranslation(["components/player"]);
+
// frames data
const { data: previewFrames } = useSWR(
@@ -544,7 +549,7 @@ function PreviewFramesPlayer({
/>
{previewFrames?.length === 0 && (
- No Preview Found for {camera.replaceAll("_", " ")}
+ {t("noPreviewFoundFor", { cameraName: camera.replaceAll("_", " ") })}
)}
{firstLoad && }
diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx
index 5c9ccb0a2..fe05a6676 100644
--- a/web/src/components/player/PreviewThumbnailPlayer.tsx
+++ b/web/src/components/player/PreviewThumbnailPlayer.tsx
@@ -21,6 +21,7 @@ import { cn } from "@/lib/utils";
import { InProgressPreview, VideoPreview } from "../preview/ScrubbablePreview";
import { Preview } from "@/types/preview";
import { baseUrl } from "@/api/baseUrl";
+import { useTranslation } from "react-i18next";
type PreviewPlayerProps = {
review: ReviewSegment;
@@ -41,6 +42,7 @@ export default function PreviewThumbnailPlayer({
onClick,
onTimeUpdate,
}: PreviewPlayerProps) {
+ const { t } = useTranslation(["components/player"]);
const apiHost = useApiHost();
const { data: config } = useSWR("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
@@ -167,7 +169,9 @@ export default function PreviewThumbnailPlayer({
const formattedDate = useFormattedTimestamp(
review.start_time,
- config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
+ config?.ui.time_format == "24hour"
+ ? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
+ : t("time.formattedTimestampExcludeSeconds", { ns: "common" }),
config?.ui?.timezone,
);
diff --git a/web/src/components/player/VideoControls.tsx b/web/src/components/player/VideoControls.tsx
index 50b2cc045..3beca352c 100644
--- a/web/src/components/player/VideoControls.tsx
+++ b/web/src/components/player/VideoControls.tsx
@@ -33,6 +33,7 @@ import {
} from "../ui/alert-dialog";
import { cn } from "@/lib/utils";
import { FaCompress, FaExpand } from "react-icons/fa";
+import { useTranslation } from "react-i18next";
type VideoControls = {
volume?: boolean;
@@ -309,6 +310,8 @@ function FrigatePlusUploadButton({
onUploadFrame,
containerRef,
}: FrigatePlusUploadButtonProps) {
+ const { t } = useTranslation(["components/player"]);
+
const [videoImg, setVideoImg] = useState();
return (
@@ -346,14 +349,16 @@ function FrigatePlusUploadButton({
className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl"
>
- Submit this frame to Frigate+?
+ {t("submitFrigatePlus.title")}
- Submit
+ {t("submitFrigatePlus.submit")}
- Cancel
+
+ {t("button.cancel", { ns: "common" })}
+
diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx
index 6c4e28e27..05521ea3b 100644
--- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx
+++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx
@@ -12,6 +12,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { VideoResolutionType } from "@/types/live";
import axios from "axios";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
/**
* Dynamically switches between video playback and scrubbing preview player.
@@ -50,6 +51,7 @@ export default function DynamicVideoPlayer({
toggleFullscreen,
containerRef,
}: DynamicVideoPlayerProps) {
+ const { t } = useTranslation(["components/player"]);
const apiHost = useApiHost();
const { data: config } = useSWR("config");
@@ -247,7 +249,7 @@ export default function DynamicVideoPlayer({
)}
{!isScrubbing && !isLoading && noRecording && (
- No recordings found for this time
+ {t("noRecordingsFoundForThisTime")}
)}
>
diff --git a/web/src/components/settings/CameraStreamingDialog.tsx b/web/src/components/settings/CameraStreamingDialog.tsx
index d4e234362..f9b2ac24b 100644
--- a/web/src/components/settings/CameraStreamingDialog.tsx
+++ b/web/src/components/settings/CameraStreamingDialog.tsx
@@ -31,6 +31,7 @@ import useSWR from "swr";
import { LuCheck, LuExternalLink, LuInfo, LuX } from "react-icons/lu";
import { Link } from "react-router-dom";
import { LiveStreamMetadata } from "@/types/live";
+import { Trans, useTranslation } from "react-i18next";
type CameraStreamingDialogProps = {
camera: string;
@@ -49,6 +50,7 @@ export function CameraStreamingDialog({
setIsDialogOpen,
onSave,
}: CameraStreamingDialogProps) {
+ const { t } = useTranslation(["components/camera"]);
const { data: config } = useSWR("config");
const [isLoading, setIsLoading] = useState(false);
@@ -167,30 +169,36 @@ export function CameraStreamingDialog({
- {camera.replaceAll("_", " ")} Streaming Settings
+ {t("group.camera.setting.title", {
+ cameraName: camera.replaceAll("_", " "),
+ })}
- Change the live streaming options for this camera group's dashboard.{" "}
- These settings are device/browser-specific.
+ group.camera.setting.desc
{!isRestreamed && (
-
Stream
+
-
Restreaming is not enabled for this camera.
+
+ {t("streaming.restreaming.disabled", {
+ ns: "components/dialog",
+ })}
+
- Info
+
+ {t("button.info", { ns: "common" })}
+
- Set up go2rtc for additional live view options and audio for
- this camera.
+ {t("streaming.restreaming.desc", { ns: "components/dialog" })}
- Read the documentation{" "}
+ {t("streaming.restreaming.readTheDocumentation", {
+ ns: "components/dialog",
+ })}
@@ -231,22 +241,23 @@ export function CameraStreamingDialog({
{supportsAudioOutput ? (
<>
- Audio is available for this stream
+ {t("group.camera.setting.audioIsAvailable")}
>
) : (
<>
- Audio is unavailable for this stream
+ {t("group.camera.setting.audioIsUnavailable")}
- Info
+
+ {t("button.info", { ns: "common" })}
+
- Audio must be output from your camera and configured
- in go2rtc for this stream.
+ {t("group.camera.setting.audio.desc")}
- Read the documentation{" "}
+ {t("group.camera.setting.audio.desc.document")}
@@ -268,7 +279,7 @@ export function CameraStreamingDialog({
)}
- Streaming Method
+ {t("group.camera.setting.streamMethod.label")}
- No Streaming
-
- Smart Streaming (recommended)
+
+ {t("group.camera.setting.streamMethod.method.noStreaming")}
+
+
+ {t("group.camera.setting.streamMethod.method.smartStreaming")}
+
+
+ {t(
+ "group.camera.setting.streamMethod.method.continuousStreaming",
+ )}
- Continuous Streaming
{streamType === "no-streaming" && (
- Camera images will only update once per minute and no live
- streaming will occur.
+ {t("group.camera.setting.streamMethod.method.noStreaming.desc")}
)}
{streamType === "smart" && (
- Smart streaming will update your camera image once per minute when
- no detectable activity is occurring to conserve bandwidth and
- resources. When activity is detected, the image seamlessly
- switches to a live stream.
+ {t(
+ "group.camera.setting.streamMethod.method.smartStreaming.desc",
+ )}
)}
{streamType === "continuous" && (
<>
- Camera image will always be a live stream when visible on the
- dashboard, even if no activity is being detected.
+ {t(
+ "group.camera.setting.streamMethod.method.continuousStreaming.desc",
+ )}
- Continuous streaming may cause high bandwidth usage and
- performance issues. Use with caution.
+ {t(
+ "group.camera.setting.streamMethod.method.continuousStreaming.desc.warning",
+ )}
>
@@ -327,14 +344,12 @@ export function CameraStreamingDialog({
htmlFor="compatibility"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
- Compatibility mode
+ {t("group.camera.setting.compatibilityMode")}
- Enable this option only if your camera's live stream is displaying
- color artifacts and has a diagonal line on the right side of the
- image.
+ {t("group.camera.setting.compatibilityMode.desc")}
@@ -343,14 +358,14 @@ export function CameraStreamingDialog({
- Cancel
+ {t("button.cancel", { ns: "common" })}
- Saving...
+ {t("button.saving", { ns: "common" })}
) : (
- "Save"
+ t("button.save", { ns: "common" })
)}
diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx
index 5c83f7720..1a47d7e1d 100644
--- a/web/src/components/settings/MotionMaskEditPane.tsx
+++ b/web/src/components/settings/MotionMaskEditPane.tsx
@@ -22,6 +22,7 @@ import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
+import { Trans, useTranslation } from "react-i18next";
type MotionMaskEditPaneProps = {
polygons?: Polygon[];
@@ -50,6 +51,7 @@ export default function MotionMaskEditPane({
snapPoints,
setSnapPoints,
}: MotionMaskEditPaneProps) {
+ const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR
("config");
@@ -105,7 +107,7 @@ export default function MotionMaskEditPane({
polygon: z.object({ name: z.string(), isFinished: z.boolean() }),
})
.refine(() => polygon?.isFinished === true, {
- message: "The polygon drawing must be finished before saving.",
+ message: t("masksAndZones.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"],
});
@@ -163,16 +165,26 @@ export default function MotionMaskEditPane({
.then((res) => {
if (res.status === 200) {
toast.success(
- `${polygon.name || "Motion Mask"} has been saved. Restart Frigate to apply changes.`,
+ polygon.name
+ ? t("masksAndZones.motionMasks.toast.success", {
+ polygonName: polygon.name,
+ })
+ : t("masksAndZones.motionMasks.toast.success.noName"),
{
position: "top-center",
},
);
updateConfig();
} else {
- toast.error(`Failed to save config changes: ${res.statusText}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage: res.statusText,
+ ns: "common",
+ }),
+ {
+ position: "top-center",
+ },
+ );
}
})
.catch((error) => {
@@ -180,7 +192,7 @@ export default function MotionMaskEditPane({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to save config changes: ${errorMessage}`, {
+ toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
})
@@ -194,6 +206,7 @@ export default function MotionMaskEditPane({
scaledHeight,
setIsLoading,
cameraConfig,
+ t,
]);
function onSubmit(values: z.infer) {
@@ -209,8 +222,8 @@ export default function MotionMaskEditPane({
}
useEffect(() => {
- document.title = "Edit Motion Mask - Frigate";
- }, []);
+ document.title = t("masksAndZones.motionMasks.documentTitle");
+ }, [t]);
if (!polygon) {
return;
@@ -220,14 +233,13 @@ export default function MotionMaskEditPane({
<>
- {polygon.name.length ? "Edit" : "New"} Motion Mask
+ {polygon.name.length
+ ? t("masksAndZones.motionMasks.edit")
+ : t("masksAndZones.motionMasks.add")}
- Motion masks are used to prevent unwanted types of motion from
- triggering detection (example: tree branches, camera timestamps).
- Motion masks should be used very sparingly , over-masking will
- make it more difficult for objects to be tracked.
+ masksAndZones.motionMasks.context
@@ -237,7 +249,7 @@ export default function MotionMaskEditPane({
rel="noopener noreferrer"
className="inline"
>
- Read the documentation{" "}
+ {t("masksAndZones.motionMasks.context.documentation")}{" "}
@@ -246,11 +258,9 @@ export default function MotionMaskEditPane({
{polygons && activePolygonIndex !== undefined && (
- {polygons[activePolygonIndex].points.length}{" "}
- {polygons[activePolygonIndex].points.length > 1 ||
- polygons[activePolygonIndex].points.length == 0
- ? "points"
- : "point"}
+ {t("masksAndZones.motionMasks.point", {
+ count: polygons[activePolygonIndex].points.length,
+ })}
{polygons[activePolygonIndex].isFinished && (
)}
@@ -265,7 +275,7 @@ export default function MotionMaskEditPane({
)}
- Click to draw a polygon on the image.
+ {t("masksAndZones.motionMasks.clickDrawPolygon")}
@@ -273,19 +283,19 @@ export default function MotionMaskEditPane({
{polygonArea && polygonArea >= 0.35 && (
<>
- The motion mask is covering {Math.round(polygonArea * 100)}% of the
- camera frame. Large motion masks are not recommended.
+ {t("masksAndZones.motionMasks.polygonAreaTooLarge", {
+ polygonArea: Math.round(polygonArea * 100),
+ })}
- Motion masks do not prevent objects from being detected. You should
- use a required zone instead.
+ {t("masksAndZones.motionMasks.polygonAreaTooLarge.tips")}
- Read the documentation{" "}
+ {t("masksAndZones.motionMasks.polygonAreaTooLarge.documentation")}{" "}
@@ -319,14 +329,14 @@ export default function MotionMaskEditPane({
- Cancel
+ {t("button.cancel", { ns: "common" })}
- Saving...
+ {t("button.saving", { ns: "common" })}
) : (
- "Save"
+ t("button.save", { ns: "common" })
)}
diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx
index 32e878c41..f8d4f3dd0 100644
--- a/web/src/components/settings/ObjectMaskEditPane.tsx
+++ b/web/src/components/settings/ObjectMaskEditPane.tsx
@@ -38,6 +38,7 @@ import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { getAttributeLabels } from "@/utils/iconUtil";
+import { useTranslation } from "react-i18next";
type ObjectMaskEditPaneProps = {
polygons?: Polygon[];
@@ -66,6 +67,7 @@ export default function ObjectMaskEditPane({
snapPoints,
setSnapPoints,
}: ObjectMaskEditPaneProps) {
+ const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR
("config");
@@ -107,7 +109,7 @@ export default function ObjectMaskEditPane({
polygon: z.object({ isFinished: z.boolean(), name: z.string() }),
})
.refine(() => polygon?.isFinished === true, {
- message: "The polygon drawing must be finished before saving.",
+ message: t("masksAndZones.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"],
});
@@ -195,16 +197,26 @@ export default function ObjectMaskEditPane({
.then((res) => {
if (res.status === 200) {
toast.success(
- `${polygon.name || "Object Mask"} has been saved. Restart Frigate to apply changes.`,
+ polygon.name
+ ? t("masksAndZones.objectMasks.toast.success", {
+ polygonName: polygon.name,
+ })
+ : t("masksAndZones.objectMasks.toast.success.noName"),
{
position: "top-center",
},
);
updateConfig();
} else {
- toast.error(`Failed to save config changes: ${res.statusText}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage: res.statusText,
+ ns: "common",
+ }),
+ {
+ position: "top-center",
+ },
+ );
}
})
.catch((error) => {
@@ -212,9 +224,15 @@ export default function ObjectMaskEditPane({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to save config changes: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage,
+ ns: "common",
+ }),
+ {
+ position: "top-center",
+ },
+ );
})
.finally(() => {
setIsLoading(false);
@@ -227,6 +245,7 @@ export default function ObjectMaskEditPane({
scaledHeight,
setIsLoading,
cameraConfig,
+ t,
],
);
@@ -243,8 +262,8 @@ export default function ObjectMaskEditPane({
}
useEffect(() => {
- document.title = "Edit Object Mask - Frigate";
- }, []);
+ document.title = t("masksAndZones.objectMasks.documentTitle");
+ }, [t]);
if (!polygon) {
return;
@@ -254,23 +273,20 @@ export default function ObjectMaskEditPane({
<>
- {polygon.name.length ? "Edit" : "New"} Object Mask
+ {polygon.name.length
+ ? t("masksAndZones.objectMasks.edit")
+ : t("masksAndZones.objectMasks.add")}
-
- Object filter masks are used to filter out false positives for a given
- object type based on location.
-
+
{t("masksAndZones.objectMasks.context")}
{polygons && activePolygonIndex !== undefined && (
- {polygons[activePolygonIndex].points.length}{" "}
- {polygons[activePolygonIndex].points.length > 1 ||
- polygons[activePolygonIndex].points.length == 0
- ? "points"
- : "point"}
+ {t("masksAndZones.objectMasks.point", {
+ count: polygons[activePolygonIndex].points.length,
+ })}
{polygons[activePolygonIndex].isFinished && (
)}
@@ -285,7 +301,7 @@ export default function ObjectMaskEditPane({
)}
- Click to draw a polygon on the image.
+ {t("masksAndZones.objectMasks.clickDrawPolygon")}
@@ -310,7 +326,9 @@ export default function ObjectMaskEditPane({
name="objects"
render={({ field }) => (
- Objects
+
+ {t("masksAndZones.objectMasks.objects")}
+
- The object type that that applies to this object mask.
+ {t("masksAndZones.objectMasks.objects.desc")}
@@ -346,25 +364,25 @@ export default function ObjectMaskEditPane({
- Cancel
+ {t("button.cancel", { ns: "common" })}
{isLoading ? (
-
Saving...
+
{t("button.saving", { ns: "common" })}
) : (
- "Save"
+ t("button.save", { ns: "common" })
)}
@@ -380,6 +398,7 @@ type ZoneObjectSelectorProps = {
};
export function ZoneObjectSelector({ camera }: ZoneObjectSelectorProps) {
+ const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR
("config");
const attributeLabels = useMemo(() => {
@@ -423,11 +442,13 @@ export function ZoneObjectSelector({ camera }: ZoneObjectSelectorProps) {
return (
<>
- All object types
+
+ {t("masksAndZones.objectMasks.objects.allObjectTypes")}
+
{allLabels.map((item) => (
- {item.replaceAll("_", " ").charAt(0).toUpperCase() + item.slice(1)}
+ {t(item, { ns: "objects" })}
))}
diff --git a/web/src/components/settings/PolygonEditControls.tsx b/web/src/components/settings/PolygonEditControls.tsx
index e3055b654..4f6ab7be6 100644
--- a/web/src/components/settings/PolygonEditControls.tsx
+++ b/web/src/components/settings/PolygonEditControls.tsx
@@ -4,6 +4,7 @@ import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
import { Button } from "../ui/button";
import { TbPolygon, TbPolygonOff } from "react-icons/tb";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
type PolygonEditControlsProps = {
polygons: Polygon[];
@@ -20,6 +21,7 @@ export default function PolygonEditControls({
snapPoints,
setSnapPoints,
}: PolygonEditControlsProps) {
+ const { t } = useTranslation(["views/settings"]);
const undo = () => {
if (activePolygonIndex === undefined || !polygons) {
return;
@@ -80,35 +82,37 @@ export default function PolygonEditControls({
- Remove last point
+
+ {t("masksAndZones.form.polygonDrawing.removeLastPoint")}
+
- Reset
+ {t("button.reset", { ns: "common" })}
setSnapPoints((prev) => !prev)}
>
{snapPoints ? (
@@ -119,7 +123,9 @@ export default function PolygonEditControls({
- {snapPoints ? "Don't snap points" : "Snap points"}
+ {snapPoints
+ ? t("masksAndZones.form.polygonDrawing.snapPoints.false")
+ : t("masksAndZones.form.polygonDrawing.snapPoints.true")}
diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx
index db3f173a3..b3cd6aea6 100644
--- a/web/src/components/settings/PolygonItem.tsx
+++ b/web/src/components/settings/PolygonItem.tsx
@@ -36,6 +36,7 @@ import { reviewQueries } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { buttonVariants } from "../ui/button";
+import { Trans, useTranslation } from "react-i18next";
type PolygonItemProps = {
polygon: Polygon;
@@ -56,6 +57,7 @@ export default function PolygonItem({
setEditPane,
handleCopyCoordinates,
}: PolygonItemProps) {
+ const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
useSWR("config");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -175,14 +177,25 @@ export default function PolygonItem({
.put(`config/set?${url}`, { requires_restart: 0 })
.then((res) => {
if (res.status === 200) {
- toast.success(`${polygon?.name} has been deleted.`, {
- position: "top-center",
- });
+ toast.success(
+ t("masksAndZones.form.polygonDrawing.delete.success", {
+ name: polygon?.name,
+ }),
+ {
+ position: "top-center",
+ },
+ );
updateConfig();
} else {
- toast.error(`Failed to save config changes: ${res.statusText}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage: res.statusText,
+ ns: "common",
+ }),
+ {
+ position: "top-center",
+ },
+ );
}
})
.catch((error) => {
@@ -190,7 +203,7 @@ export default function PolygonItem({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to save config changes: ${errorMessage}`, {
+ toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
})
@@ -198,7 +211,7 @@ export default function PolygonItem({
setIsLoading(false);
});
},
- [updateConfig, cameraConfig],
+ [updateConfig, cameraConfig, t],
);
const handleDelete = () => {
@@ -253,19 +266,30 @@ export default function PolygonItem({
>
- Confirm Delete
+
+ {t("masksAndZones.form.polygonDrawing.delete.title")}
+
- Are you sure you want to delete the{" "}
- {polygon.type.replace("_", " ")} {polygon.name} ?
+
+ masksAndZones.form.polygonDrawing.delete.desc
+
- Cancel
+
+ {t("button.cancel", { ns: "common" })}
+
- Delete
+ {t("button.delete", { ns: "common" })}
@@ -279,26 +303,26 @@ export default function PolygonItem({
{
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
- Edit
+ {t("button.edit", { ns: "common" })}
handleCopyCoordinates(index)}
>
- Copy
+ {t("button.copy", { ns: "common" })}
setDeleteDialogOpen(true)}
>
- Delete
+ {t("button.delete", { ns: "common" })}
@@ -317,7 +341,9 @@ export default function PolygonItem({
}}
/>
- Edit
+
+ {t("button.edit", { ns: "common" })}
+
@@ -330,7 +356,9 @@ export default function PolygonItem({
onClick={() => handleCopyCoordinates(index)}
/>
- Copy coordinates
+
+ {t("button.copyCoordinates", { ns: "common" })}
+
@@ -344,7 +372,9 @@ export default function PolygonItem({
onClick={() => !isLoading && setDeleteDialogOpen(true)}
/>
- Delete
+
+ {t("button.delete", { ns: "common" })}
+
)}
diff --git a/web/src/components/settings/SearchSettings.tsx b/web/src/components/settings/SearchSettings.tsx
index 788072ff1..aff3bdaf6 100644
--- a/web/src/components/settings/SearchSettings.tsx
+++ b/web/src/components/settings/SearchSettings.tsx
@@ -17,8 +17,9 @@ import FilterSwitch from "../filter/FilterSwitch";
import { SearchFilter, SearchSource } from "@/types/search";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
+import { useTranslation } from "react-i18next";
-type SearchSettingsProps = {
+type ExploreSettingsProps = {
className?: string;
columns: number;
defaultView: string;
@@ -27,7 +28,7 @@ type SearchSettingsProps = {
setDefaultView: (view: string) => void;
onUpdateFilter: (filter: SearchFilter) => void;
};
-export default function SearchSettings({
+export default function ExploreSettings({
className,
columns,
setColumns,
@@ -35,7 +36,8 @@ export default function SearchSettings({
filter,
setDefaultView,
onUpdateFilter,
-}: SearchSettingsProps) {
+}: ExploreSettingsProps) {
+ const { t } = useTranslation(["components/filter"]);
const { data: config } = useSWR("config");
const [open, setOpen] = useState(false);
@@ -46,21 +48,20 @@ export default function SearchSettings({
const trigger = (
- Settings
+ {t("explore.settings.title")}
);
const content = (
-
Default View
+
{t("explore.settings.defaultView")}
- When no filters are selected, display a summary of the most recent
- tracked objects per label, or display an unfiltered grid.
+ {t("explore.settings.defaultView.desc")}
setDefaultView(value)}
>
- {defaultView == "summary" ? "Summary" : "Unfiltered Grid"}
+ {defaultView == "summary"
+ ? t("explore.settings.defaultView.summary")
+ : t("explore.settings.defaultView.unfilteredGrid")}
@@ -78,7 +81,9 @@ export default function SearchSettings({
className="cursor-pointer"
value={value}
>
- {value == "summary" ? "Summary" : "Unfiltered Grid"}
+ {value == "summary"
+ ? t("explore.settings.defaultView.summary")
+ : t("explore.settings.defaultView.unfilteredGrid")}
))}
@@ -90,9 +95,9 @@ export default function SearchSettings({
-
Grid Columns
+
{t("explore.settings.gridColumns")}
- Select the number of columns in the grid view.
+ {t("explore.settings.gridColumns.desc")}
@@ -148,20 +153,22 @@ export function SearchTypeContent({
searchSources,
setSearchSources,
}: SearchTypeContentProps) {
+ const { t } = useTranslation(["components/filter"]);
return (
<>
-
Search Source
+
+ {t("explore.settings.searchSource.label")}
+
- Choose whether to search the thumbnails or descriptions of your
- tracked objects.
+ {t("explore.settings.searchSource.desc")}
{
const updatedSources = searchSources ? [...searchSources] : [];
@@ -179,7 +186,7 @@ export function SearchTypeContent({
}}
/>
{
const updatedSources = searchSources ? [...searchSources] : [];
diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx
index 7adb3e194..93bf22ccd 100644
--- a/web/src/components/settings/ZoneEditPane.tsx
+++ b/web/src/components/settings/ZoneEditPane.tsx
@@ -29,6 +29,7 @@ import { toast } from "sonner";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import ActivityIndicator from "../indicators/activity-indicator";
import { getAttributeLabels } from "@/utils/iconUtil";
+import { Trans, useTranslation } from "react-i18next";
type ZoneEditPaneProps = {
polygons?: Polygon[];
@@ -59,6 +60,7 @@ export default function ZoneEditPane({
snapPoints,
setSnapPoints,
}: ZoneEditPaneProps) {
+ const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR("config");
@@ -102,7 +104,9 @@ export default function ZoneEditPane({
name: z
.string()
.min(2, {
- message: "Zone name must be at least 2 characters.",
+ message: t(
+ "masksAndZones.form.zoneName.error.mustBeAtLeastTwoCharacters",
+ ),
})
.transform((val: string) => val.trim().replace(/\s+/g, "_"))
.refine(
@@ -110,7 +114,9 @@ export default function ZoneEditPane({
return !cameras.map((cam) => cam.name).includes(value);
},
{
- message: "Zone name must not be the name of a camera.",
+ message: t(
+ "masksAndZones.form.zoneName.error.mustNotBeSameWithCamera",
+ ),
},
)
.refine(
@@ -123,7 +129,7 @@ export default function ZoneEditPane({
return !otherPolygonNames.includes(value);
},
{
- message: "Zone name already exists on this camera.",
+ message: t("masksAndZones.form.zoneName.error.alreadyExists"),
},
)
.refine(
@@ -131,27 +137,31 @@ export default function ZoneEditPane({
return !value.includes(".");
},
{
- message: "Zone name must not contain a period.",
+ message: t(
+ "masksAndZones.form.zoneName.error.mustNotContainPeriod",
+ ),
},
)
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
- message: "Zone name has an illegal character.",
+ message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
}),
inertia: z.coerce
.number()
.min(1, {
- message: "Inertia must be above 0.",
+ message: t("masksAndZones.form.inertia.error.mustBeAboveZero"),
})
.or(z.literal("")),
loitering_time: z.coerce
.number()
.min(0, {
- message: "Loitering time must be greater than or equal to 0.",
+ message: t(
+ "masksAndZones.form.loiteringTime.error.mustBeGreaterOrEqualZero",
+ ),
})
.optional()
.or(z.literal("")),
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
- message: "The polygon drawing must be finished before saving.",
+ message: t("masksAndZones.polygonDrawing.error.mustBeFinished"),
}),
objects: z.array(z.string()).optional(),
review_alerts: z.boolean().default(false).optional(),
@@ -160,28 +170,28 @@ export default function ZoneEditPane({
lineA: z.coerce
.number()
.min(0.1, {
- message: "Distance must be greater than or equal to 0.1",
+ message: t("masksAndZones.form.distance.error"),
})
.optional()
.or(z.literal("")),
lineB: z.coerce
.number()
.min(0.1, {
- message: "Distance must be greater than or equal to 0.1",
+ message: t("masksAndZones.form.distance.error"),
})
.optional()
.or(z.literal("")),
lineC: z.coerce
.number()
.min(0.1, {
- message: "Distance must be greater than or equal to 0.1",
+ message: t("masksAndZones.form.distance.error"),
})
.optional()
.or(z.literal("")),
lineD: z.coerce
.number()
.min(0.1, {
- message: "Distance must be greater than or equal to 0.1",
+ message: t("masksAndZones.form.distance.error"),
})
.optional()
.or(z.literal("")),
@@ -201,7 +211,7 @@ export default function ZoneEditPane({
return true;
},
{
- message: "All distance fields must be filled to use speed estimation.",
+ message: t("masksAndZones.form.distance.error.mustBeFilled"),
path: ["speedEstimation"],
},
)
@@ -215,8 +225,9 @@ export default function ZoneEditPane({
);
},
{
- message:
- "Zones with loitering times greater than 0 should not be used with speed estimation.",
+ message: t(
+ "masksAndZones.zones.speedThreshold.toast.error.loiteringTimeError",
+ ),
path: ["loitering_time"],
},
);
@@ -255,11 +266,11 @@ export default function ZoneEditPane({
polygon.points.length !== 4
) {
toast.error(
- "Speed estimation has been disabled for this zone. Zones with speed estimation must have exactly 4 points.",
+ t("masksAndZones.zones.speedThreshold.toast.error.pointLengthError"),
);
form.setValue("speedEstimation", false);
}
- }, [polygon, form]);
+ }, [polygon, form, t]);
const saveToConfig = useCallback(
async (
@@ -319,7 +330,7 @@ export default function ZoneEditPane({
// Wait for the config to be updated
mutatedConfig = await updateConfig();
} catch (error) {
- toast.error(`Failed to save config changes.`, {
+ toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
return;
@@ -401,16 +412,24 @@ export default function ZoneEditPane({
.then((res) => {
if (res.status === 200) {
toast.success(
- `Zone (${zoneName}) has been saved. Restart Frigate to apply changes.`,
+ t("masksAndZones.zones.toast.success", {
+ zoneName,
+ }),
{
position: "top-center",
},
);
updateConfig();
} else {
- toast.error(`Failed to save config changes: ${res.statusText}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage: res.statusText,
+ ns: "common",
+ }),
+ {
+ position: "top-center",
+ },
+ );
}
})
.catch((error) => {
@@ -418,9 +437,15 @@ export default function ZoneEditPane({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to save config changes: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage,
+ ns: "common",
+ }),
+ {
+ position: "top-center",
+ },
+ );
})
.finally(() => {
setIsLoading(false);
@@ -434,6 +459,7 @@ export default function ZoneEditPane({
scaledHeight,
setIsLoading,
cameraConfig,
+ t,
],
);
@@ -454,8 +480,8 @@ export default function ZoneEditPane({
}
useEffect(() => {
- document.title = "Edit Zone - Frigate";
- }, []);
+ document.title = t("masksAndZones.zones.documentTitle");
+ }, [t]);
if (!polygon) {
return;
@@ -465,23 +491,21 @@ export default function ZoneEditPane({
<>
- {polygon.name.length ? "Edit" : "New"} Zone
+ {polygon.name.length
+ ? t("masksAndZones.zones.edit")
+ : t("masksAndZones.zones.add")}
-
- 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.
-
+
{t("masksAndZones.zones.desc")}
{polygons && activePolygonIndex !== undefined && (
- {polygons[activePolygonIndex].points.length}{" "}
- {polygons[activePolygonIndex].points.length > 1 ||
- polygons[activePolygonIndex].points.length == 0
- ? "points"
- : "point"}
+ {t("masksAndZones.zones.point", {
+ count: polygons[activePolygonIndex].points.length,
+ })}
+
{polygons[activePolygonIndex].isFinished && (
)}
@@ -496,7 +520,7 @@ export default function ZoneEditPane({
)}
- Click to draw a polygon on the image.
+ {t("masksAndZones.zones.clickDrawPolygon")}
@@ -508,17 +532,16 @@ export default function ZoneEditPane({
name="name"
render={({ field }) => (
- Name
+ {t("masksAndZones.zones.name")}
- Name must be at least 2 characters and must not be the name of
- a camera or another zone.
+ {t("masksAndZones.zones.name.tips")}
@@ -530,7 +553,7 @@ export default function ZoneEditPane({
name="inertia"
render={({ field }) => (
- Inertia
+ {t("masksAndZones.zones.inertia")}
- Specifies how many frames that an object must be in a zone
- before they are considered in the zone. Default: 3
+
+ masksAndZones.zones.inertia.desc
+
@@ -552,7 +576,7 @@ export default function ZoneEditPane({
name="loitering_time"
render={({ field }) => (
- Loitering Time
+ {t("masksAndZones.zones.loiteringTime")}
- Sets a minimum amount of time in seconds that the object must
- be in the zone for it to activate. Default: 0
+
+ masksAndZones.zones.loiteringTime.desc
+
@@ -570,9 +595,9 @@ export default function ZoneEditPane({
/>
- Objects
+ {t("masksAndZones.zones.objects")}
- List of objects that apply to this zone.
+ {t("masksAndZones.zones.objects.desc")}
- Speed Estimation
+ {t("masksAndZones.zones.speedEstimation")}
0) {
toast.error(
- "Zones with loitering times greater than 0 should not be used with speed estimation.",
+ t(
+ "masksAndZones.zones.speedEstimation.loiteringTimeError",
+ ),
);
}
field.onChange(checked);
@@ -637,8 +666,7 @@ export default function ZoneEditPane({
- Enable speed estimation for objects in this zone. The zone
- must have exactly 4 points.
+ {t("masksAndZones.zones.speedEstimation.desc")}
@@ -750,8 +778,12 @@ export default function ZoneEditPane({
render={({ field }) => (
- Speed Threshold (
- {config?.ui.unit_system == "imperial" ? "mph" : "kph"})
+ {t("masksAndZones.zones.speedThreshold", {
+ unit:
+ config?.ui.unit_system == "imperial"
+ ? t("unit.speed.mph")
+ : t("unit.speed.kph"),
+ })}
- Specifies a minimum speed for objects to be considered
- in this zone.
+ {t("masksAndZones.zones.speedThreshold.desc")}
@@ -782,25 +813,25 @@ export default function ZoneEditPane({
- Cancel
+ {t("button.cancel", { ns: "common" })}
{isLoading ? (
-
Saving...
+
{t("button.saving", { ns: "common" })}
) : (
- "Save"
+ t("button.save", { ns: "common" })
)}
@@ -823,6 +854,7 @@ export function ZoneObjectSelector({
selectedLabels,
updateLabelFilter,
}: ZoneObjectSelectorProps) {
+ const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR("config");
const attributeLabels = useMemo(() => {
@@ -880,7 +912,7 @@ export function ZoneObjectSelector({
- All Objects
+ {t("masksAndZones.zones.allObjects")}
- {item.replaceAll("_", " ")}
+ {t(item, { ns: "objects" })}
{
setIsOpen(false);
if (
@@ -429,7 +433,7 @@ export function DateRangePicker({
}
}}
>
- Apply
+ {t("button.apply", { ns: "common"})}
{
@@ -438,9 +442,9 @@ export function DateRangePicker({
onReset?.();
}}
variant="ghost"
- aria-label="Reset"
+ aria-label={t("button.reset", { ns: "common" })}
>
- Reset
+ {t("button.reset", { ns: "common"})}
diff --git a/web/src/components/ui/calendar.tsx b/web/src/components/ui/calendar.tsx
index 792df0890..34e4eac08 100644
--- a/web/src/components/ui/calendar.tsx
+++ b/web/src/components/ui/calendar.tsx
@@ -1,12 +1,24 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
-
+import { enUS, Locale, zhCN } from "date-fns/locale";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
+import i18n from "@/utils/i18n";
export type CalendarProps = React.ComponentProps;
+
+let locale: Locale;
+switch(i18n.language) {
+ case "zh-CN":
+ locale = zhCN;
+ break;
+ default:
+ locale = enUS;
+ break;
+}
+
function Calendar({
className,
classNames,
@@ -15,6 +27,7 @@ function Calendar({
}: CalendarProps) {
return (
;
@@ -196,6 +197,7 @@ const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { t } = useTranslation(["views/explore"]);
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
@@ -210,13 +212,13 @@ const CarouselPrevious = React.forwardRef<
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
- aria-label="Previous slide"
+ aria-label={t("objectLifecycle.carousel.previous")}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
- Previous slide
+ {t("objectLifecycle.carousel.previous")}
);
});
@@ -226,6 +228,7 @@ const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { t } = useTranslation(["views/explore"]);
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
@@ -240,13 +243,13 @@ const CarouselNext = React.forwardRef<
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
- aria-label="Next slide"
+ aria-label={t("objectLifecycle.carousel.next")}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
- Next slide
+ {t("objectLifecycle.carousel.next")}
);
});
diff --git a/web/src/components/ui/pagination.tsx b/web/src/components/ui/pagination.tsx
index ea40d196d..6d7a25073 100644
--- a/web/src/components/ui/pagination.tsx
+++ b/web/src/components/ui/pagination.tsx
@@ -3,11 +3,14 @@ import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
+import { useTranslation } from "react-i18next"
+
+const { t } = useTranslation(["common"])
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
@@ -64,13 +67,13 @@ const PaginationPrevious = ({
...props
}: React.ComponentProps) => (
- Previous
+ {t("pagination.previous")}
)
PaginationPrevious.displayName = "PaginationPrevious"
@@ -80,12 +83,12 @@ const PaginationNext = ({
...props
}: React.ComponentProps) => (
- Next
+ {t("pagination.next")}
)
@@ -101,7 +104,7 @@ const PaginationEllipsis = ({
{...props}
>
- More pages
+ {t("pagination.more")}
)
PaginationEllipsis.displayName = "PaginationEllipsis"
diff --git a/web/src/context/language-provider.tsx b/web/src/context/language-provider.tsx
new file mode 100644
index 000000000..e0c965494
--- /dev/null
+++ b/web/src/context/language-provider.tsx
@@ -0,0 +1,77 @@
+import { createContext, useContext, useState, useEffect, useMemo } from "react";
+import i18next from "i18next";
+
+type LanguageProviderState = {
+ language: string;
+ systemLanguage: string;
+ setLanguage: (language: string) => void;
+};
+
+const initialState: LanguageProviderState = {
+ language: i18next.language || "en",
+ systemLanguage: "en",
+ setLanguage: () => null,
+};
+
+const LanguageProviderContext =
+ createContext(initialState);
+
+export function LanguageProvider({
+ children,
+ defaultLanguage = "en",
+ storageKey = "frigate-ui-language",
+ ...props
+}: {
+ children: React.ReactNode;
+ defaultLanguage?: string;
+ storageKey?: string;
+}) {
+ const [language, setLanguage] = useState(() => {
+ try {
+ const storedData = localStorage.getItem(storageKey);
+ const newLanguage = storedData || defaultLanguage;
+ i18next.changeLanguage(newLanguage);
+ return newLanguage;
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("Error retrieving language data from storage:", error);
+ return defaultLanguage;
+ }
+ });
+
+ const systemLanguage = useMemo(() => {
+ if (typeof window === "undefined") return "en";
+ return window.navigator.language;
+ }, []);
+
+ useEffect(() => {
+ if (language === systemLanguage) return;
+ i18next.changeLanguage(language);
+ }, [language, systemLanguage]);
+
+ const value = {
+ language,
+ systemLanguage,
+ setLanguage: (language: string) => {
+ localStorage.setItem(storageKey, language);
+ setLanguage(language);
+ window.location.reload();
+ },
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const useLanguage = () => {
+ const context = useContext(LanguageProviderContext);
+
+ if (context === undefined)
+ throw new Error("useLanguage must be used within a LanguageProvider");
+
+ return context;
+};
diff --git a/web/src/context/providers.tsx b/web/src/context/providers.tsx
index b0a5f55c9..310ab1724 100644
--- a/web/src/context/providers.tsx
+++ b/web/src/context/providers.tsx
@@ -5,6 +5,7 @@ import { ApiProvider } from "@/api";
import { IconContext } from "react-icons";
import { TooltipProvider } from "@/components/ui/tooltip";
import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
+import { LanguageProvider } from "./language-provider";
import { StreamingSettingsProvider } from "./streaming-settings-provider";
import { AuthProvider } from "./auth-context";
@@ -18,15 +19,17 @@ function providers({ children }: TProvidersProps) {
-
-
-
-
- {children}
-
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
+
diff --git a/web/src/context/theme-provider.tsx b/web/src/context/theme-provider.tsx
index 764e0d8e9..2fe14ee66 100644
--- a/web/src/context/theme-provider.tsx
+++ b/web/src/context/theme-provider.tsx
@@ -23,9 +23,7 @@ export const colorSchemes: ColorScheme[] = [
// eslint-disable-next-line react-refresh/only-export-components
export const friendlyColorSchemeName = (className: string): string => {
const words = className.split("-").slice(1); // Exclude the first word (e.g., 'theme')
- return words
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
- .join(" ");
+ return "menu.theme." + words.join(".");
};
type ThemeProviderProps = {
diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts
index 28eb8c67d..ea3257fbc 100644
--- a/web/src/hooks/use-camera-activity.ts
+++ b/web/src/hooks/use-camera-activity.ts
@@ -54,7 +54,7 @@ export function useCameraActivity(
// handle camera activity
const hasActiveObjects = useMemo(
- () => objects.filter((obj) => !obj.stationary).length > 0,
+ () => objects?.filter((obj) => !obj?.stationary)?.length > 0,
[objects],
);
diff --git a/web/src/hooks/use-navigation.ts b/web/src/hooks/use-navigation.ts
index daed383d3..9bb780a2e 100644
--- a/web/src/hooks/use-navigation.ts
+++ b/web/src/hooks/use-navigation.ts
@@ -31,35 +31,35 @@ export default function useNavigation(
id: ID_LIVE,
variant,
icon: FaVideo,
- title: "Live",
+ title: "menu.live",
url: "/",
},
{
id: ID_REVIEW,
variant,
icon: MdVideoLibrary,
- title: "Review",
+ title: "menu.review",
url: "/review",
},
{
id: ID_EXPLORE,
variant,
icon: IoSearch,
- title: "Explore",
+ title: "menu.explore",
url: "/explore",
},
{
id: ID_EXPORT,
variant,
icon: FaCompactDisc,
- title: "Export",
+ title: "menu.export",
url: "/export",
},
{
id: ID_PLAYGROUND,
variant,
icon: LuConstruction,
- title: "UI Playground",
+ title: "menu.uiPlayground",
url: "/playground",
enabled: ENV !== "production",
},
@@ -67,11 +67,11 @@ export default function useNavigation(
id: ID_FACE_LIBRARY,
variant,
icon: TbFaceId,
- title: "Face Library",
+ title: "menu.faceLibrary",
url: "/faces",
enabled: isDesktop && config?.face_recognition.enabled,
},
] as NavData[],
- [config?.face_recognition.enabled, variant],
+ [config?.face_recognition?.enabled, variant],
);
}
diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts
index 23b819327..41f1b0dc6 100644
--- a/web/src/hooks/use-stats.ts
+++ b/web/src/hooks/use-stats.ts
@@ -11,7 +11,10 @@ import useDeepMemo from "./use-deep-memo";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { useFrigateStats } from "@/api/ws";
+import { useTranslation } from "react-i18next";
+
export default function useStats(stats: FrigateStats | undefined) {
+ const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR("config");
const memoizedStats = useDeepMemo(stats);
@@ -72,7 +75,10 @@ export default function useStats(stats: FrigateStats | undefined) {
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
problems.push({
- text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high FFMPEG CPU usage (${ffmpegAvg}%)`,
+ text: t("stats.ffmpegHighCpuUsage", {
+ camera: capitalizeFirstLetter(name.replaceAll("_", " ")),
+ ffmpegAvg,
+ }),
color: "text-danger",
relevantLink: "/system#cameras",
});
@@ -80,7 +86,10 @@ export default function useStats(stats: FrigateStats | undefined) {
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
problems.push({
- text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high detect CPU usage (${detectAvg}%)`,
+ text: t("stats.detectHighCpuUsage", {
+ camera: capitalizeFirstLetter(name.replaceAll("_", " ")),
+ detectAvg,
+ }),
color: "text-danger",
relevantLink: "/system#cameras",
});
@@ -88,7 +97,7 @@ export default function useStats(stats: FrigateStats | undefined) {
});
return problems;
- }, [config, memoizedStats]);
+ }, [config, memoizedStats, t]);
return { potentialProblems };
}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index f25366e5e..334c8f2a5 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -2,6 +2,8 @@ import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
+import "@/utils/i18n";
+import "react-i18next";
ReactDOM.createRoot(document.getElementById("root")!).render(
diff --git a/web/src/pages/AccessDenied.tsx b/web/src/pages/AccessDenied.tsx
index 53d83282b..ca33b4cc8 100644
--- a/web/src/pages/AccessDenied.tsx
+++ b/web/src/pages/AccessDenied.tsx
@@ -1,21 +1,21 @@
import Heading from "@/components/ui/heading";
import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
import { FaExclamationTriangle } from "react-icons/fa";
export default function AccessDenied() {
+ const { t } = useTranslation(["common"]);
useEffect(() => {
- document.title = "Access Denied - Frigate";
- }, []);
+ document.title = t("accessDenied.documentTitle");
+ }, [t]);
return (
- Access Denied
+ {t("accessDenied.title")}
-
- You don't have permission to view this page.
-
+
{t("accessDenied.desc")}
);
}
diff --git a/web/src/pages/ConfigEditor.tsx b/web/src/pages/ConfigEditor.tsx
index df859beb5..f0efee90a 100644
--- a/web/src/pages/ConfigEditor.tsx
+++ b/web/src/pages/ConfigEditor.tsx
@@ -14,6 +14,7 @@ import { toast } from "sonner";
import { LuCopy, LuSave } from "react-icons/lu";
import { MdOutlineRestartAlt } from "react-icons/md";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
+import { useTranslation } from "react-i18next";
import { useRestart } from "@/api/ws";
type SaveOptions = "saveonly" | "restart";
@@ -24,11 +25,12 @@ type ApiErrorResponse = {
};
function ConfigEditor() {
+ const { t } = useTranslation(["views/configEditor"]);
const apiHost = useApiHost();
useEffect(() => {
- document.title = "Config Editor - Frigate";
- }, []);
+ document.title = t("documentTitle");
+ }, [t]);
const { data: config } = useSWR("config/raw");
@@ -64,7 +66,7 @@ function ConfigEditor() {
toast.success(response.data.message, { position: "top-center" });
}
} catch (error) {
- toast.error("Error saving config", { position: "top-center" });
+ toast.error(t("toast.error.savingError"), { position: "top-center" });
const axiosError = error as AxiosError;
const errorMessage =
@@ -76,7 +78,7 @@ function ConfigEditor() {
throw new Error(errorMessage);
}
},
- [editorRef],
+ [editorRef, t],
);
const handleCopyConfig = useCallback(async () => {
@@ -85,8 +87,10 @@ function ConfigEditor() {
}
copy(editorRef.current.getValue());
- toast.success("Config copied to clipboard.", { position: "top-center" });
- }, [editorRef]);
+ toast.success(t("toast.success.copyToClipboard"), {
+ position: "top-center",
+ });
+ }, [editorRef, t]);
const handleSaveAndRestart = useCallback(async () => {
try {
@@ -208,38 +212,38 @@ function ConfigEditor() {
- Config Editor
+ {t("configEditor")}
handleCopyConfig()}
>
- Copy Config
+ {t("copyConfig")}
- Save & Restart
+ {t("saveAndRestart")}
onHandleSaveConfig("saveonly")}
>
- Save Only
+ {t("saveOnly")}
diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx
index 50db5211e..d477f5693 100644
--- a/web/src/pages/Events.tsx
+++ b/web/src/pages/Events.tsx
@@ -23,9 +23,12 @@ import EventView from "@/views/events/EventView";
import { RecordingView } from "@/views/recording/RecordingView";
import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import useSWR from "swr";
export default function Events() {
+ const { t } = useTranslation(["views/events"]);
+
const { data: config } = useSWR
("config", {
revalidateOnFocus: false,
});
@@ -77,11 +80,11 @@ export default function Events() {
useEffect(() => {
if (recording) {
- document.title = "Recordings - Frigate";
+ document.title = t("recordings.documentTitle");
} else {
- document.title = `Review - Frigate`;
+ document.title = t("documentTitle");
}
- }, [recording, severity]);
+ }, [recording, severity, t]);
// review filter
diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx
index 31fe81d04..061ab3fba 100644
--- a/web/src/pages/Explore.tsx
+++ b/web/src/pages/Explore.tsx
@@ -15,6 +15,7 @@ import { formatSecondsToDuration } from "@/utils/dateUtil";
import SearchView from "@/views/search/SearchView";
import { useCallback, useEffect, useMemo, useState } from "react";
import { isMobileOnly } from "react-device-detect";
+import { useTranslation } from "react-i18next";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { TbExclamationCircle } from "react-icons/tb";
import { Link } from "react-router-dom";
@@ -27,6 +28,8 @@ const API_LIMIT = 25;
export default function Explore() {
// search field handler
+ const { t } = useTranslation(["views/explore"]);
+
const { data: config } = useSWR("config", {
revalidateOnFocus: false,
});
@@ -201,7 +204,9 @@ export default function Explore() {
revalidateAll: false,
onError: (error) => {
toast.error(
- `Error fetching tracked objects: ${error.response.data.message}`,
+ t("fetchingTrackedObjectsFailed", {
+ errorMessage: error.response.data.message,
+ }),
{
position: "top-center",
},
@@ -357,13 +362,12 @@ export default function Explore() {
-
Explore is Unavailable
+
{t("exploreIsUnavailable.title")}
{embeddingsReindexing && allModelsLoaded && (
<>
- Explore can be used after tracked object embeddings have
- finished reindexing.
+ {t("exploreIsUnavailable.embeddingsReindexing.context")}
{reindexState.time_remaining === -1
- ? "Starting up..."
- : "Estimated time remaining:"}
+ ? t(
+ "exploreIsUnavailable.embeddingsReindexing.startingUp",
+ )
+ : t(
+ "exploreIsUnavailable.embeddingsReindexing.estimatedTime",
+ )}
{reindexState.time_remaining >= 0 &&
(formatSecondsToDuration(reindexState.time_remaining) ||
- "Finishing shortly")}
+ t(
+ "exploreIsUnavailable.embeddingsReindexing.finishingShortly",
+ ))}
)}
- Thumbnails embedded:
+ t("exploreIsUnavailable.embeddingsReindexing.step.thumbnailsEmbedded")
{reindexState.thumbnails}
- Descriptions embedded:
+ t("exploreIsUnavailable.embeddingsReindexing.step.descriptionsEmbedded")
{reindexState.descriptions}
- Tracked objects processed:
+ t("exploreIsUnavailable.embeddingsReindexing.step.trackedObjectsProcessed")
{reindexState.processed_objects} /{" "}
{reindexState.total_objects}
@@ -412,26 +422,32 @@ export default function Explore() {
{!allModelsLoaded && (
<>
- Frigate is downloading the necessary embeddings models to
- support the Semantic Search feature. This may take several
- minutes depending on the speed of your network connection.
+ {t("exploreIsUnavailable.downloadingModels.context")}
{renderModelStateIcon(visionModelState)}
- Vision model
+ {t(
+ "exploreIsUnavailable.downloadingModels.setup.visionModel",
+ )}
{renderModelStateIcon(visionFeatureExtractorState)}
- Vision model feature extractor
+ {t(
+ "exploreIsUnavailable.downloadingModels.setup.visionModelFeatureExtractor",
+ )}
{renderModelStateIcon(textModelState)}
- Text model
+ {t(
+ "exploreIsUnavailable.downloadingModels.setup.textModel",
+ )}
{renderModelStateIcon(textTokenizerState)}
- Text tokenizer
+ {t(
+ "exploreIsUnavailable.downloadingModels.setup.textTokenizer",
+ )}
{(textModelState === "error" ||
@@ -439,12 +455,11 @@ export default function Explore() {
visionModelState === "error" ||
visionFeatureExtractorState === "error") && (
- An error has occurred. Check Frigate logs.
+ {t("exploreIsUnavailable.downloadingModels.error")}
)}
- You may want to reindex the embeddings of your tracked objects
- once the models are downloaded.
+ {t("exploreIsUnavailable.downloadingModels.tips.context")}
- Read the documentation{" "}
+ {t(
+ "exploreIsUnavailable.downloadingModels.tips.documentation",
+ )}{" "}
diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx
index 93cfa6b11..e1ed08a8d 100644
--- a/web/src/pages/Exports.tsx
+++ b/web/src/pages/Exports.tsx
@@ -17,18 +17,22 @@ import { useSearchEffect } from "@/hooks/use-overlay-state";
import { cn } from "@/lib/utils";
import { DeleteClipType, Export } from "@/types/export";
import axios from "axios";
+
import { useCallback, useEffect, useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
+import { useTranslation } from "react-i18next";
+
import { LuFolderX } from "react-icons/lu";
import { toast } from "sonner";
import useSWR from "swr";
function Exports() {
+ const { t } = useTranslation(["views/exports"]);
const { data: exports, mutate } = useSWR
("exports");
useEffect(() => {
- document.title = "Export - Frigate";
- }, []);
+ document.title = t("documentTitle");
+ }, [t]);
// Search
@@ -97,12 +101,12 @@ function Exports() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to rename export: ${errorMessage}`, {
+ toast.error(t("toast.error.renameExportFailed", { errorMessage }), {
position: "top-center",
});
});
},
- [mutate],
+ [mutate, t],
);
return (
@@ -115,20 +119,22 @@ function Exports() {
>
- Delete Export
+ {t("deleteExport")}
- Are you sure you want to delete {deleteClip?.exportName}?
+ {t("deleteExport.desc", { exportName: deleteClip?.exportName })}
- Cancel
+
+ {t("button.cancel", { ns: "common" })}
+
onHandleDelete()}
>
- Delete
+ {t("button.delete", { ns: "common" })}
@@ -176,7 +182,7 @@ function Exports() {
setSearch(e.target.value)}
/>
@@ -204,7 +210,7 @@ function Exports() {
) : (
- No exports found
+ {t("noExports")}
)}
diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx
index 9099c4977..33fbb69d1 100644
--- a/web/src/pages/FaceLibrary.tsx
+++ b/web/src/pages/FaceLibrary.tsx
@@ -25,18 +25,21 @@ import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
import { LuImagePlus, LuRefreshCw, LuScanFace, LuTrash2 } from "react-icons/lu";
import { toast } from "sonner";
import useSWR from "swr";
export default function FaceLibrary() {
+ const { t } = useTranslation(["views/faceLibrary"]);
+
const { data: config } = useSWR("config");
// title
useEffect(() => {
- document.title = "Face Library - Frigate";
- }, []);
+ document.title = t("documentTitle");
+ }, [t]);
const [page, setPage] = useState();
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
@@ -94,7 +97,7 @@ export default function FaceLibrary() {
if (resp.status == 200) {
setUpload(false);
refreshFaces();
- toast.success("Successfully uploaded image.", {
+ toast.success(t("toast.success.uploadedImage"), {
position: "top-center",
});
}
@@ -104,12 +107,12 @@ export default function FaceLibrary() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to upload image: ${errorMessage}`, {
+ toast.error(t("toast.error.uploadingImageFailed", { errorMessage }), {
position: "top-center",
});
});
},
- [pageToggle, refreshFaces],
+ [pageToggle, refreshFaces, t],
);
const onAddName = useCallback(
@@ -124,7 +127,7 @@ export default function FaceLibrary() {
if (resp.status == 200) {
setAddFace(false);
refreshFaces();
- toast.success("Successfully add face library.", {
+ toast.success(t("toast.success.addFaceLibrary"), {
position: "top-center",
});
}
@@ -134,12 +137,12 @@ export default function FaceLibrary() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to set face name: ${errorMessage}`, {
+ toast.error(t("toast.error.addFaceLibraryFailed", { errorMessage }), {
position: "top-center",
});
});
},
- [refreshFaces],
+ [refreshFaces, t],
);
// face multiselect
@@ -176,7 +179,7 @@ export default function FaceLibrary() {
setSelectedFaces([]);
if (resp.status == 200) {
- toast.success(`Successfully deleted face.`, {
+ toast.success(t("toast.success.deletedFace"), {
position: "top-center",
});
refreshFaces();
@@ -187,11 +190,11 @@ export default function FaceLibrary() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to delete: ${errorMessage}`, {
+ toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
position: "top-center",
});
});
- }, [selectedFaces, refreshFaces]);
+ }, [selectedFaces, refreshFaces, t]);
// keyboard
@@ -219,15 +222,15 @@ export default function FaceLibrary() {
- Train
+ {t("train.title")}
|
>
@@ -267,7 +270,7 @@ export default function FaceLibrary() {
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
- aria-label={`Select ${item}`}
+ aria-label={t("selectItem", { item })}
>
{item} ({faceData[item].length})
@@ -282,19 +285,19 @@ export default function FaceLibrary() {
onDelete()}>
- Delete Face Attempts
+ {t("button.deleteFaceAttempts")}
) : (
setAddFace(true)}>
- Add Face
+ {t("button.addFace")}
{pageToggle != "train" && (
setUpload(true)}>
- Upload Image
+ {t("button.uploadImage")}
)}
@@ -370,6 +373,7 @@ function FaceAttempt({
onClick,
onRefresh,
}: FaceAttemptProps) {
+ const { t } = useTranslation(["views/faceLibrary"]);
const data = useMemo(() => {
const parts = image.split("-");
@@ -386,7 +390,7 @@ function FaceAttempt({
.post(`/faces/train/${trainName}/classify`, { training_file: image })
.then((resp) => {
if (resp.status == 200) {
- toast.success(`Successfully trained face.`, {
+ toast.success(t("toast.success.trainedFace"), {
position: "top-center",
});
onRefresh();
@@ -397,12 +401,12 @@ function FaceAttempt({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to train: ${errorMessage}`, {
+ toast.error(t("toast.error.trainFailed", { errorMessage }), {
position: "top-center",
});
});
},
- [image, onRefresh],
+ [image, onRefresh, t],
);
const onReprocess = useCallback(() => {
@@ -410,7 +414,7 @@ function FaceAttempt({
.post(`/faces/reprocess`, { training_file: image })
.then((resp) => {
if (resp.status == 200) {
- toast.success(`Successfully updated face score.`, {
+ toast.success(t("toast.success.updatedFaceScore"), {
position: "top-center",
});
onRefresh();
@@ -421,11 +425,11 @@ function FaceAttempt({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to update face score: ${errorMessage}`, {
+ toast.error(t("toast.error.updateFaceScoreFailed", { errorMessage }), {
position: "top-center",
});
});
- }, [image, onRefresh]);
+ }, [image, onRefresh, t]);
return (
- Train Face as:
+ {t("trainFaceAs")}
{faceNames.map((faceName) => (
- Train Face as Person
+ {t("trainFaceAsPerson")}
@@ -484,7 +488,7 @@ function FaceAttempt({
onClick={() => onReprocess()}
/>
- Reprocess Face
+ {t("button.reprocessFace")}
@@ -519,12 +523,13 @@ type FaceImageProps = {
onRefresh: () => void;
};
function FaceImage({ name, image, onRefresh }: FaceImageProps) {
+ const { t } = useTranslation(["views/faceLibrary"]);
const onDelete = useCallback(() => {
axios
.post(`/faces/${name}/delete`, { ids: [image] })
.then((resp) => {
if (resp.status == 200) {
- toast.success(`Successfully deleted face.`, {
+ toast.success(t("toast.success.deletedFace"), {
position: "top-center",
});
onRefresh();
@@ -535,11 +540,11 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to delete: ${errorMessage}`, {
+ toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
position: "top-center",
});
});
- }, [name, image, onRefresh]);
+ }, [name, image, onRefresh, t]);
return (
@@ -559,7 +564,7 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) {
onClick={onDelete}
/>
- Delete Face Attempt
+ {t("button.deleteFaceAttempts")}
diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx
index 016f3cba1..b697f8c05 100644
--- a/web/src/pages/Live.tsx
+++ b/web/src/pages/Live.tsx
@@ -9,10 +9,13 @@ import { FrigateConfig } from "@/types/frigateConfig";
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
import LiveCameraView from "@/views/live/LiveCameraView";
import LiveDashboardView from "@/views/live/LiveDashboardView";
+import { useTranslation } from "react-i18next";
+
import { useEffect, useMemo, useRef } from "react";
import useSWR from "swr";
function Live() {
+ const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR
("config");
// selection
@@ -64,13 +67,17 @@ function Live() {
.split("_")
.filter((text) => text)
.map((text) => text[0].toUpperCase() + text.substring(1));
- document.title = `${capitalized.join(" ")} - Live - Frigate`;
+ document.title = t("documentTitle.withCamera", {
+ camera: capitalized.join(" "),
+ });
} else if (cameraGroup && cameraGroup != "default") {
- document.title = `${cameraGroup[0].toUpperCase()}${cameraGroup.substring(1)} - Live - Frigate`;
+ document.title = t("documentTitle.withCamera", {
+ camera: `${cameraGroup[0].toUpperCase()}${cameraGroup.substring(1)}`,
+ });
} else {
- document.title = "Live - Frigate";
+ document.title = t("documentTitle", { ns: "views/live" });
}
- }, [cameraGroup, selectedCameraName]);
+ }, [cameraGroup, selectedCameraName, t]);
// settings
diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx
index 196e6fdd7..a3e3f1fc8 100644
--- a/web/src/pages/Logs.tsx
+++ b/web/src/pages/Logs.tsx
@@ -34,8 +34,10 @@ import { debounce } from "lodash";
import { isIOS, isMobile } from "react-device-detect";
import { isPWA } from "@/utils/isPWA";
import { isInIframe } from "@/utils/isIFrame";
+import { useTranslation } from "react-i18next";
function Logs() {
+ const { t } = useTranslation(["views/system"]);
const [logService, setLogService] = useState("frigate");
const tabsRef = useRef(null);
const lazyLogWrapperRef = useRef(null);
@@ -47,8 +49,8 @@ function Logs() {
const lastFetchedIndexRef = useRef(-1);
useEffect(() => {
- document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`;
- }, [logService]);
+ document.title = t("documentTitle.logs." + logService);
+ }, [logService, t]);
useEffect(() => {
if (tabsRef.current) {
@@ -104,13 +106,16 @@ function Logs() {
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
- toast.error(`Error fetching logs: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("logs.toast.error.fetchingLogsFailed", { errorMessage }),
+ {
+ position: "top-center",
+ },
+ );
}
return [];
},
- [logService, filterLines],
+ [logService, filterLines, t],
);
const fetchInitialLogs = useCallback(async () => {
@@ -132,13 +137,13 @@ function Logs() {
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
- toast.error(`Error fetching logs: ${errorMessage}`, {
+ toast.error(t("logs.toast.error.fetchingLogsFailed", { errorMessage }), {
position: "top-center",
});
} finally {
setIsLoading(false);
}
- }, [logService, filterLines, filterSeverity]);
+ }, [logService, filterLines, filterSeverity, t]);
const abortControllerRef = useRef(null);
@@ -203,10 +208,12 @@ function Logs() {
error instanceof Error
? error.message
: "An unknown error occurred";
- toast.error(`Error while streaming logs: ${errorMessage}`);
+ toast.error(
+ t("logs.toast.error.whileStreamingLogs", { errorMessage }),
+ );
}
});
- }, [logService, filterSeverity]);
+ }, [logService, filterSeverity, t]);
useEffect(() => {
setIsLoading(true);
@@ -285,13 +292,13 @@ function Logs() {
fetchInitialLogs()
.then(() => {
copy(logs.join("\n"));
- toast.success("Copied logs to clipboard");
+ toast.success(t("logs.copy.success"));
})
.catch(() => {
- toast.error("Could not copy logs to clipboard");
+ toast.error(t("logs.copy.error"));
});
}
- }, [logs, fetchInitialLogs]);
+ }, [logs, fetchInitialLogs, t]);
const handleDownloadLogs = useCallback(() => {
axios
@@ -496,23 +503,25 @@ function Logs() {
- Copy to Clipboard
+ {t("logs.copy.label")}
- Download
+
+ {t("button.download", { ns: "common" })}
+
-
Type
-
Timestamp
+
+ {t("logs.type.label")}
+
+
{t("logs.type.timestamp")}
- Tag
+ {t("logs.type.tag")}
-
Message
+
{t("logs.type.message")}
@@ -566,9 +577,7 @@ function Logs() {
-
- Logs are streaming from the server
-
+ {t("logs.tips")}
)}
diff --git a/web/src/pages/NoMatch.tsx b/web/src/pages/NoMatch.tsx
index 933794abb..a315d3b07 100644
--- a/web/src/pages/NoMatch.tsx
+++ b/web/src/pages/NoMatch.tsx
@@ -1,15 +1,17 @@
import Heading from "@/components/ui/heading";
import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
function NoMatch() {
+ const { t } = useTranslation(["common"]);
useEffect(() => {
- document.title = "Not Found - Frigate";
- }, []);
+ document.title = t("notFound.documentTitle");
+ }, [t]);
return (
<>
- 404
- Page not found
+ {t("notFound.title")}
+ {t("notFound.desc")}
>
);
}
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx
index 8e0ead43a..92eb083de 100644
--- a/web/src/pages/Settings.tsx
+++ b/web/src/pages/Settings.tsx
@@ -43,13 +43,14 @@ import { useInitialCameraState } from "@/api/ws";
import { isInIframe } from "@/utils/isIFrame";
import { isPWA } from "@/utils/isPWA";
import { useIsAdmin } from "@/hooks/use-is-admin";
+import { useTranslation } from "react-i18next";
const allSettingsViews = [
- "UI settings",
- "classification settings",
- "camera settings",
- "masks / zones",
- "motion tuner",
+ "uiSettings",
+ "classificationSettings",
+ "cameraSettings",
+ "masksAndZones",
+ "motionTuner",
"debug",
"users",
"notifications",
@@ -57,7 +58,8 @@ const allSettingsViews = [
type SettingsType = (typeof allSettingsViews)[number];
export default function Settings() {
- const [page, setPage] = useState("UI settings");
+ const { t } = useTranslation(["views/settings"]);
+ const [page, setPage] = useState("uiSettings");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const tabsRef = useRef(null);
@@ -69,7 +71,7 @@ export default function Settings() {
const isAdmin = useIsAdmin();
- const allowedViewsForViewer: SettingsType[] = ["UI settings", "debug"];
+ const allowedViewsForViewer: SettingsType[] = ["uiSettings", "debug"];
const visibleSettingsViews = !isAdmin
? allowedViewsForViewer
: allSettingsViews;
@@ -133,7 +135,7 @@ export default function Settings() {
setSelectedCamera(firstEnabledCamera.name);
} else if (
!cameraEnabledStates[selectedCamera] &&
- page !== "camera settings"
+ page !== "cameraSettings"
) {
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
const firstEnabledCamera =
@@ -163,8 +165,8 @@ export default function Settings() {
useSearchEffect("page", (page: string) => {
if (allSettingsViews.includes(page as SettingsType)) {
// Restrict viewer to UI settings
- if (!isAdmin && !["UI settings", "debug"].includes(page)) {
- setPage("UI settings");
+ if (!isAdmin && !["uiSettings", "debug"].includes(page)) {
+ setPage("uiSettings");
} else {
setPage(page as SettingsType);
}
@@ -183,8 +185,8 @@ export default function Settings() {
});
useEffect(() => {
- document.title = "Settings - Frigate";
- }, []);
+ document.title = t("documentTitle.default");
+ }, [t]);
return (
@@ -199,8 +201,8 @@ export default function Settings() {
onValueChange={(value: SettingsType) => {
if (value) {
// Restrict viewer navigation
- if (!isAdmin && !["UI settings", "debug"].includes(value)) {
- setPageToggle("UI settings");
+ if (!isAdmin && !["uiSettings", "debug"].includes(value)) {
+ setPageToggle("uiSettings");
} else {
setPageToggle(value);
}
@@ -210,12 +212,15 @@ export default function Settings() {
{visibleSettingsViews.map((item) => (
- {item}
+ {t("menu." + item)}
))}
@@ -223,11 +228,11 @@ export default function Settings() {
{(page == "debug" ||
- page == "camera settings" ||
- page == "masks / zones" ||
- page == "motion tuner") && (
+ page == "cameraSettings" ||
+ page == "masksAndZones" ||
+ page == "motionTuner") && (
- {page == "masks / zones" && (
+ {page == "masksAndZones" && (
- {page == "UI settings" &&
}
- {page == "classification settings" && (
+ {page == "uiSettings" &&
}
+ {page == "classificationSettings" && (
)}
{page == "debug" && (
)}
- {page == "camera settings" && (
+ {page == "cameraSettings" && (
)}
- {page == "masks / zones" && (
+ {page == "masksAndZones" && (
)}
- {page == "motion tuner" && (
+ {page == "motionTuner" && (
- You have unsaved changes.
+
+ {t("dialog.unsavedChanges.title")}
+
- Do you want to save your changes before continuing?
+ {t("dialog.unsavedChanges.desc")}
handleDialog(false)}>
- Cancel
+ {t("button.cancel", { ns: "common" })}
handleDialog(true)}>
- Save
+ {t("button.save", { ns: "common" })}
@@ -317,6 +324,8 @@ function CameraSelectButton({
cameraEnabledStates,
currentPage,
}: CameraSelectButtonProps) {
+ const { t } = useTranslation(["views/settings"]);
+
const [open, setOpen] = useState(false);
if (!allCameras.length) {
@@ -332,7 +341,7 @@ function CameraSelectButton({
{selectedCamera == undefined
- ? "No Camera"
+ ? t("cameraSetting.noCamera")
: selectedCamera.replaceAll("_", " ")}
@@ -342,7 +351,7 @@ function CameraSelectButton({
{isMobile && (
<>
- Camera
+ {t("cameraSetting.camera")}
>
@@ -351,7 +360,7 @@ function CameraSelectButton({
{allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name];
- const isCameraSettingsPage = currentPage === "camera settings";
+ const isCameraSettingsPage = currentPage === "cameraSettings";
return (
("config", {
revalidateOnFocus: false,
});
@@ -52,9 +53,9 @@ function System() {
useEffect(() => {
if (pageToggle) {
- document.title = `${capitalizeFirstLetter(pageToggle)} Stats - Frigate`;
+ document.title = t("documentTitle." + pageToggle);
}
- }, [pageToggle]);
+ }, [pageToggle, t]);
// stats collection
@@ -91,7 +92,9 @@ function System() {
{item == "features" && }
{item == "storage" && }
{item == "cameras" && }
- {isDesktop && {item}
}
+ {isDesktop && (
+ {t(item + ".title")}
+ )}
))}
@@ -99,13 +102,14 @@ function System() {
{lastUpdated && (
- Last refreshed:
+ {t("lastRefreshed")}
+
)}
-
System
+
{t("title")}
{statsSnapshot && (
{statsSnapshot.service.version}
diff --git a/web/src/utils/browserUtil.ts b/web/src/utils/browserUtil.ts
index b6a82fb54..13589752e 100644
--- a/web/src/utils/browserUtil.ts
+++ b/web/src/utils/browserUtil.ts
@@ -1,4 +1,5 @@
import copy from "copy-to-clipboard";
+import { t } from "i18next";
import { toast } from "sonner";
export function shareOrCopy(url: string, title?: string) {
@@ -9,7 +10,7 @@ export function shareOrCopy(url: string, title?: string) {
});
} else {
copy(url);
- toast.success("Copied URL to clipboard.", {
+ toast.success(t("toast.copyUrlToClipboard"), {
position: "top-center",
});
}
diff --git a/web/src/utils/i18n.ts b/web/src/utils/i18n.ts
new file mode 100644
index 000000000..fc5af10e1
--- /dev/null
+++ b/web/src/utils/i18n.ts
@@ -0,0 +1,93 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import HttpBackend from "i18next-http-backend";
+
+i18n
+ .use(initReactI18next)
+ .use(HttpBackend)
+ .init({
+ fallbackLng: "en", // use en if detected lng is not available
+
+ backend: {
+ loadPath: "/locales/{{lng}}/{{ns}}.json",
+ },
+
+ ns: [
+ "common",
+ "objects",
+ "audio",
+ "components/camera",
+ "components/dialog",
+ "components/filter",
+ "components/icons",
+ "components/player",
+ "views/events",
+ "views/explore",
+ "views/live",
+ "views/settings",
+ "views/system",
+ "views/exports",
+ "views/explore",
+ ],
+ defaultNS: "common",
+
+ react: {
+ transSupportBasicHtmlNodes: true,
+ transKeepBasicHtmlNodesFor: [
+ "br",
+ "strong",
+ "i",
+ "em",
+ "li",
+ "p",
+ "code",
+ "span",
+ "p",
+ "ul",
+ "li",
+ "ol",
+ ],
+ },
+ interpolation: {
+ escapeValue: false, // react already safes from xss
+ },
+ keySeparator: ".",
+ parseMissingKeyHandler: (key: string) => {
+ const parts = key.split(".");
+
+ // Handle special cases for objects and audio
+ if (parts[0] === "object" || parts[0] === "audio") {
+ return (
+ parts[1]
+ ?.split("_")
+ .map(
+ (word) =>
+ word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
+ )
+ .join(" ") || key
+ );
+ }
+
+ // For nested keys, try to make them more readable
+ if (parts.length > 1) {
+ const lastPart = parts[parts.length - 1];
+ return lastPart
+ .split("_")
+ .map(
+ (word) =>
+ word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
+ )
+ .join(" ");
+ }
+
+ // For single keys, just capitalize and format
+ return key
+ .split("_")
+ .map(
+ (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
+ )
+ .join(" ");
+ },
+ });
+
+export default i18n;
diff --git a/web/src/utils/lifecycleUtil.ts b/web/src/utils/lifecycleUtil.ts
index f59f3eac9..74e3ca97d 100644
--- a/web/src/utils/lifecycleUtil.ts
+++ b/web/src/utils/lifecycleUtil.ts
@@ -1,8 +1,10 @@
import { ObjectLifecycleSequence } from "@/types/timeline";
+import { t } from "i18next";
export function getLifecycleItemDescription(
lifecycleItem: ObjectLifecycleSequence,
) {
+ // can't use useTranslation here
const label = (
(Array.isArray(lifecycleItem.data.sub_label)
? lifecycleItem.data.sub_label[0]
@@ -11,37 +13,63 @@ export function getLifecycleItemDescription(
switch (lifecycleItem.class_type) {
case "visible":
- return `${label} detected`;
+ return t("objectLifecycle.lifecycleItemDesc.visible", {
+ label,
+ ns: "views/explore",
+ });
case "entered_zone":
- return `${label} entered ${lifecycleItem.data.zones
- .join(" and ")
- .replaceAll("_", " ")}`;
+ return t("objectLifecycle.lifecycleItemDesc.entered_zone", {
+ label,
+ ns: "views/explore",
+ zones: lifecycleItem.data.zones.join(" and ").replaceAll("_", " "),
+ });
case "active":
- return `${label} became active`;
+ return t("objectLifecycle.lifecycleItemDesc.active", {
+ label,
+ ns: "views/explore",
+ });
case "stationary":
- return `${label} became stationary`;
+ return t("objectLifecycle.lifecycleItemDesc.stationary", {
+ label,
+ ns: "views/explore",
+ });
case "attribute": {
let title = "";
if (
lifecycleItem.data.attribute == "face" ||
lifecycleItem.data.attribute == "license_plate"
) {
- title = `${lifecycleItem.data.attribute.replaceAll(
- "_",
- " ",
- )} detected for ${label}`;
+ title = t(
+ "objectLifecycle.lifecycleItemDesc.attribute.faceOrLicense_plate",
+ {
+ label,
+ attribute: lifecycleItem.data.attribute.replaceAll("_", " "),
+ ns: "views/explore",
+ },
+ );
} else {
- title = `${
- lifecycleItem.data.label
- } recognized as ${lifecycleItem.data.attribute.replaceAll("_", " ")}`;
+ title = t("objectLifecycle.lifecycleItemDesc.attribute.other", {
+ label: lifecycleItem.data.label,
+ attribute: lifecycleItem.data.attribute.replaceAll("_", " "),
+ ns: "views/explore",
+ });
}
return title;
}
case "gone":
- return `${label} left`;
+ return t("objectLifecycle.lifecycleItemDesc.gone", {
+ label,
+ ns: "views/explore",
+ });
case "heard":
- return `${label} heard`;
+ return t("objectLifecycle.lifecycleItemDesc.heard", {
+ label,
+ ns: "views/explore",
+ });
case "external":
- return `${label} detected`;
+ return t("objectLifecycle.lifecycleItemDesc.external", {
+ label,
+ ns: "views/explore",
+ });
}
}
diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx
index 0c0cc2571..a0d904d35 100644
--- a/web/src/views/events/EventView.tsx
+++ b/web/src/views/events/EventView.tsx
@@ -54,7 +54,9 @@ import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter";
import { GiSoundWaves } from "react-icons/gi";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog";
+
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
+import { useTranslation } from "react-i18next";
type EventViewProps = {
reviewItems?: SegmentedReviewData;
@@ -94,6 +96,7 @@ export default function EventView({
pullLatestData,
updateFilter,
}: EventViewProps) {
+ const { t } = useTranslation(["views/events"]);
const { data: config } = useSWR
("config");
const contentRef = useRef(null);
@@ -198,8 +201,10 @@ export default function EventView({
.then((response) => {
if (response.status == 200) {
toast.success(
- "Successfully started export. View the file in the /exports folder.",
- { position: "top-center" },
+ t("export.toast.success", { ns: "components/dialog" }),
+ {
+ position: "top-center",
+ },
);
}
})
@@ -208,12 +213,18 @@ export default function EventView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to start export: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("export.toast.error.failed", {
+ ns: "components/dialog",
+ message: errorMessage,
+ }),
+ {
+ position: "top-center",
+ },
+ );
});
},
- [reviewItems],
+ [reviewItems, t],
);
const [motionOnly, setMotionOnly] = useState(false);
@@ -266,7 +277,7 @@ export default function EventView({
{isMobileOnly ? (
- Alerts
+ {t("alerts")}
{reviewCounts.alert > -1 ? (
` ∙ ${reviewCounts.alert}`
) : (
@@ -300,7 +311,7 @@ export default function EventView({
severityToggle != "detection" && "text-muted-foreground",
)}
value="detection"
- aria-label="Select detections"
+ aria-label={t("detections")}
>
{isMobileOnly ? (
- Detections
+ {t("detections")}
{reviewCounts.detection > -1 ? (
` ∙ ${reviewCounts.detection}`
) : (
@@ -337,14 +348,14 @@ export default function EventView({
severityToggle != "significant_motion" && "text-muted-foreground",
)}
value="significant_motion"
- aria-label="Select motion"
+ aria-label={t("motion.label")}
>
{isMobileOnly ? (
) : (
<>
-
Motion
+
{t("motion.label")}
>
)}
@@ -461,6 +472,8 @@ function DetectionReview({
setSelectedReviews,
pullLatestData,
}: DetectionReviewProps) {
+ const { t } = useTranslation(["views/events"]);
+
const reviewTimelineRef = useRef
(null);
// detail
@@ -712,7 +725,7 @@ function DetectionReview({
{!loading && currentItems?.length === 0 && (
- There are no {severity.replace(/_/g, " ")}s to review
+ {t("empty." + severity.replace(/_/g, " "))}
)}
@@ -779,14 +792,14 @@ function DetectionReview({
{
setSelectedReviews([]);
markAllItemsAsReviewed(currentItems ?? []);
}}
>
- Mark these items as reviewed
+ {t("markTheseItemsAsReviewed")}
)}
@@ -862,6 +875,7 @@ function MotionReview({
motionOnly = false,
onOpenRecording,
}: MotionReviewProps) {
+ const { t } = useTranslation(["views/events"]);
const segmentDuration = 30;
const { data: config } = useSWR("config");
@@ -1051,7 +1065,7 @@ function MotionReview({
return (
- No motion data found
+ {t("empty.motion")}
);
}
diff --git a/web/src/views/explore/ExploreView.tsx b/web/src/views/explore/ExploreView.tsx
index e754bc1bf..8b239a7e0 100644
--- a/web/src/views/explore/ExploreView.tsx
+++ b/web/src/views/explore/ExploreView.tsx
@@ -21,6 +21,7 @@ import TimeAgo from "@/components/dynamic/TimeAgo";
import SearchResultActions from "@/components/menu/SearchResultActions";
import { SearchTab } from "@/components/overlay/detail/SearchDetailDialog";
import { FrigateConfig } from "@/types/frigateConfig";
+import { useTranslation } from "react-i18next";
type ExploreViewProps = {
searchDetail: SearchResult | undefined;
@@ -35,11 +36,12 @@ export default function ExploreView({
setSimilaritySearch,
onSelectSearch,
}: ExploreViewProps) {
+ const { t } = useTranslation(["views/explore"]);
// title
useEffect(() => {
- document.title = "Explore - Frigate";
- }, []);
+ document.title = t("documentTitle");
+ }, [t]);
// data
@@ -137,6 +139,7 @@ function ThumbnailRow({
setSimilaritySearch,
onSelectSearch,
}: ThumbnailRowType) {
+ const { t } = useTranslation(["views/explore"]);
const navigate = useNavigate();
const handleSearch = (label: string) => {
@@ -149,15 +152,13 @@ function ThumbnailRow({
return (
- {objectType.replaceAll("_", " ")}
+ {t(objectType, { ns: "objects" })}
{searchResults && (
- (
- {
+ {t("trackedObjectsCount", {
// @ts-expect-error we know this is correct
- searchResults[0].event_count
- }{" "}
- tracked objects){" "}
+ count: searchResults[0].event_count,
+ })}
)}
{isValidating &&
}
@@ -225,7 +226,7 @@ function ExploreThumbnailImage({
};
const handleShowObjectLifecycle = () => {
- onSelectSearch(event, false, "object lifecycle");
+ onSelectSearch(event, false, "object_lifecycle");
};
const handleShowSnapshot = () => {
diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx
index d0da3e5ac..2fc21357d 100644
--- a/web/src/views/live/DraggableGridLayout.tsx
+++ b/web/src/views/live/DraggableGridLayout.tsx
@@ -50,6 +50,7 @@ import { Toaster } from "@/components/ui/sonner";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
+import { useTranslation } from "react-i18next";
type DraggableGridLayoutProps = {
cameras: CameraConfig[];
@@ -79,6 +80,7 @@ export default function DraggableGridLayout({
fullscreen,
toggleFullscreen,
}: DraggableGridLayoutProps) {
+ const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR
("config");
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@@ -658,7 +660,9 @@ export default function DraggableGridLayout({
- {isEditMode ? "Exit Editing" : "Edit Layout"}
+ {isEditMode
+ ? t("editLayout.exitEdit")
+ : t("editLayout.label")}
{!isEditMode && (
@@ -676,7 +680,9 @@ export default function DraggableGridLayout({
- {isEditMode ? "Exit Editing" : "Edit Camera Group"}
+ {isEditMode
+ ? t("editLayout.exitEdit")
+ : t("editLayout.group.label")}
)}
@@ -694,7 +700,9 @@ export default function DraggableGridLayout({
- {fullscreen ? "Exit Fullscreen" : "Fullscreen"}
+ {fullscreen
+ ? t("button.exitFullscreen", { ns: "common" })
+ : t("button.fullscreen", { ns: "common" })}
>
diff --git a/web/src/views/live/LiveBirdseyeView.tsx b/web/src/views/live/LiveBirdseyeView.tsx
index ecaea9ca9..ca28180bf 100644
--- a/web/src/views/live/LiveBirdseyeView.tsx
+++ b/web/src/views/live/LiveBirdseyeView.tsx
@@ -14,6 +14,7 @@ import {
isSafari,
useMobileOrientation,
} from "react-device-detect";
+import { useTranslation } from "react-i18next";
import { FaCompress, FaExpand } from "react-icons/fa";
import { IoMdArrowBack } from "react-icons/io";
import { LuPictureInPicture } from "react-icons/lu";
@@ -32,6 +33,7 @@ export default function LiveBirdseyeView({
fullscreen,
toggleFullscreen,
}: LiveBirdseyeViewProps) {
+ const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR
("config");
const navigate = useNavigate();
const { isPortrait } = useMobileOrientation();
@@ -144,12 +146,16 @@ export default function LiveBirdseyeView({
{!fullscreen ? (
navigate(-1)}
>
- {isDesktop && Back
}
+ {isDesktop && (
+
+ {t("button.back", { ns: "common" })}
+
+ )}
) : (
@@ -164,7 +170,11 @@ export default function LiveBirdseyeView({
variant={fullscreen ? "overlay" : "primary"}
Icon={fullscreen ? FaCompress : FaExpand}
isActive={fullscreen}
- title={fullscreen ? "Close" : "Fullscreen"}
+ title={
+ fullscreen
+ ? t("button.close", { ns: "common" })
+ : t("button.fullscreen", { ns: "common" })
+ }
onClick={toggleFullscreen}
/>
)}
@@ -174,7 +184,11 @@ export default function LiveBirdseyeView({
variant={fullscreen ? "overlay" : "primary"}
Icon={LuPictureInPicture}
isActive={pip}
- title={pip ? "Close" : "Picture in Picture"}
+ title={
+ pip
+ ? t("button.close", { ns: "common" })
+ : t("button.pictureInPicture", { ns: "common" })
+ }
onClick={() => {
if (!pip) {
setPip(true);
diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx
index f9cc3cb42..05be55ff9 100644
--- a/web/src/views/live/LiveCameraView.tsx
+++ b/web/src/views/live/LiveCameraView.tsx
@@ -103,6 +103,7 @@ import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import useSWR from "swr";
import { cn } from "@/lib/utils";
import { useSessionPersistence } from "@/hooks/use-session-persistence";
+
import {
Select,
SelectContent,
@@ -117,6 +118,7 @@ import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
+import { Trans, useTranslation } from "react-i18next";
type LiveCameraViewProps = {
config?: FrigateConfig;
@@ -132,6 +134,7 @@ export default function LiveCameraView({
fullscreen,
toggleFullscreen,
}: LiveCameraViewProps) {
+ const { t } = useTranslation(["views/live"]);
const navigate = useNavigate();
const { isPortrait } = useMobileOrientation();
const mainRef = useRef(null);
@@ -427,16 +430,20 @@ export default function LiveCameraView({
>
navigate(-1)}
>
- {isDesktop && Back
}
+ {isDesktop && (
+
+ {t("button.back", { ns: "common" })}
+
+ )}
{
navigate("review", {
@@ -452,7 +459,11 @@ export default function LiveCameraView({
}}
>
- {isDesktop && History
}
+ {isDesktop && (
+
+ {t("button.history", { ns: "common" })}
+
+ )}
) : (
@@ -465,13 +476,15 @@ export default function LiveCameraView({
{fullscreen && (
navigate(-1)}
>
{isDesktop && (
- Back
+
+ {t("button.back", { ns: "common" })}
+
)}
)}
@@ -481,7 +494,11 @@ export default function LiveCameraView({
variant={fullscreen ? "overlay" : "primary"}
Icon={fullscreen ? FaCompress : FaExpand}
isActive={fullscreen}
- title={fullscreen ? "Close" : "Fullscreen"}
+ title={
+ fullscreen
+ ? t("button.close", { ns: "common" })
+ : t("button.fullscreen", { ns: "common" })
+ }
onClick={toggleFullscreen}
/>
)}
@@ -491,7 +508,11 @@ export default function LiveCameraView({
variant={fullscreen ? "overlay" : "primary"}
Icon={LuPictureInPicture}
isActive={pip}
- title={pip ? "Close" : "Picture in Picture"}
+ title={
+ pip
+ ? t("button.close", { ns: "common" })
+ : t("button.pictureInPicture", { ns: "common" })
+ }
onClick={() => {
if (!pip) {
setPip(true);
@@ -667,6 +688,7 @@ function PtzControlPanel({
clickOverlay: boolean;
setClickOverlay: React.Dispatch
>;
}) {
+ const { t } = useTranslation(["views/live"]);
const { data: ptz } = useSWR(`${camera}/ptz/info`);
const { send: sendPtz } = usePtzCommand(camera);
@@ -752,7 +774,7 @@ function PtzControlPanel({
{ptz?.features?.includes("pt") && (
<>
{
e.preventDefault();
sendPtz("MOVE_LEFT");
@@ -767,7 +789,7 @@ function PtzControlPanel({
{
e.preventDefault();
sendPtz("MOVE_UP");
@@ -782,7 +804,7 @@ function PtzControlPanel({
{
e.preventDefault();
sendPtz("MOVE_DOWN");
@@ -797,7 +819,7 @@ function PtzControlPanel({
{
e.preventDefault();
sendPtz("MOVE_RIGHT");
@@ -816,7 +838,7 @@ function PtzControlPanel({
{ptz?.features?.includes("zoom") && (
<>
{
e.preventDefault();
sendPtz("ZOOM_IN");
@@ -831,7 +853,7 @@ function PtzControlPanel({
{
e.preventDefault();
sendPtz("ZOOM_OUT");
@@ -854,14 +876,19 @@ function PtzControlPanel({
setClickOverlay(!clickOverlay)}
>
- {clickOverlay ? "Disable" : "Enable"} click to move
+
+ {clickOverlay
+ ? t("ptz.move.clickMove.disable")
+ : t("ptz.move.clickMove.enable")}{" "}
+ click to move
+
@@ -872,7 +899,7 @@ function PtzControlPanel({
-
+
@@ -894,7 +921,7 @@ function PtzControlPanel({
- PTZ camera presets
+ {t("ptz.presets")}
@@ -904,6 +931,7 @@ function PtzControlPanel({
}
function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
+ const { t } = useTranslation(["views/live"]);
const rankMap = { all: 0, motion: 1, active_objects: 2 };
const getValidMode = (retain?: { mode?: string }): keyof typeof rankMap => {
const mode = retain?.mode;
@@ -918,13 +946,25 @@ function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
? recordRetainMode
: alertsRetainMode;
- const source = effectiveRetainMode === recordRetainMode ? "camera" : "alerts";
+ const source =
+ effectiveRetainMode === recordRetainMode
+ ? t("camera", { ns: "views/events" })
+ : t("alerts", { ns: "views/events" });
return effectiveRetainMode !== "all" ? (
- Your {source} recording retention configuration is set to{" "}
- mode: {effectiveRetainMode}
, so this on-demand recording will
- only keep segments with {effectiveRetainMode.replaceAll("_", " ")}.
+
+ effectiveRetainMode.notAllTips
+
) : null;
}
@@ -967,6 +1007,8 @@ function FrigateCameraFeatures({
supports2WayTalk,
cameraEnabled,
}: FrigateCameraFeaturesProps) {
+ const { t } = useTranslation(["views/live"]);
+
const { payload: detectState, send: sendDetect } = useDetectState(
camera.name,
);
@@ -1010,14 +1052,9 @@ function FrigateCameraFeatures({
setIsRecording(true);
const toastId = toast.success(
-
- Started manual on-demand recording.
-
+
{t("manualRecording.started")}
{!camera.record.enabled || camera.record.alerts.retain.days == 0 ? (
-
- Since recording is disabled or restricted in the config for this
- camera, only a snapshot will be saved.
-
+
{t("manualRecording.recordDisabledTips")}
) : (
)}
@@ -1030,11 +1067,11 @@ function FrigateCameraFeatures({
setActiveToastId(toastId);
}
} catch (error) {
- toast.error("Failed to start manual on-demand recording.", {
+ toast.error(t("manualRecording.failedToStart"), {
position: "top-center",
});
}
- }, [camera]);
+ }, [camera, t]);
const endEvent = useCallback(() => {
if (activeToastId) {
@@ -1047,16 +1084,16 @@ function FrigateCameraFeatures({
});
recordingEventIdRef.current = null;
setIsRecording(false);
- toast.success("Ended manual on-demand recording.", {
+ toast.success(t("manualRecording.ended"), {
position: "top-center",
});
}
} catch (error) {
- toast.error("Failed to end manual on-demand recording.", {
+ toast.error(t("manualRecording.failedToEnd"), {
position: "top-center",
});
}
- }, [activeToastId]);
+ }, [activeToastId, t]);
const handleEventButtonClick = useCallback(() => {
if (isRecording) {
@@ -1092,7 +1129,9 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
isActive={enabledState == "ON"}
- title={`${enabledState == "ON" ? "Disable" : "Enable"} Camera`}
+ title={
+ enabledState == "ON" ? t("camera.disable") : t("camera.enable")
+ }
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
disabled={false}
/>
@@ -1101,7 +1140,9 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={detectState == "ON" ? MdPersonSearch : MdPersonOff}
isActive={detectState == "ON"}
- title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
+ title={
+ detectState == "ON" ? t("detect.disable") : t("detect.enable")
+ }
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
@@ -1110,7 +1151,11 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={recordState == "ON" ? LuVideo : LuVideoOff}
isActive={recordState == "ON"}
- title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
+ title={
+ recordState == "ON"
+ ? t("recording.disable")
+ : t("recording.enable")
+ }
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
@@ -1119,7 +1164,11 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography}
isActive={snapshotState == "ON"}
- title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
+ title={
+ snapshotState == "ON"
+ ? t("snapshots.disable")
+ : t("snapshots.enable")
+ }
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
@@ -1129,7 +1178,11 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={audioState == "ON" ? LuEar : LuEarOff}
isActive={audioState == "ON"}
- title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
+ title={
+ audioState == "ON"
+ ? t("audioDetect.disable")
+ : t("audioDetect.enable")
+ }
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
@@ -1142,7 +1195,11 @@ function FrigateCameraFeatures({
autotrackingState == "ON" ? TbViewfinder : TbViewfinderOff
}
isActive={autotrackingState == "ON"}
- title={`${autotrackingState == "ON" ? "Disable" : "Enable"} Autotracking`}
+ title={
+ autotrackingState == "ON"
+ ? t("autotracking.disable")
+ : t("autotracking.enable")
+ }
onClick={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
@@ -1159,7 +1216,7 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={isRecording ? TbRecordMail : TbRecordMailOff}
isActive={isRecording}
- title={`${isRecording ? "Stop" : "Start"} on-demand recording`}
+ title={t("manualRecording." + (isRecording ? "stop" : "start"))}
onClick={handleEventButtonClick}
disabled={!cameraEnabled}
/>
@@ -1180,20 +1237,29 @@ function FrigateCameraFeatures({
{!isRestreamed && (
-
Stream
+
+ {t("streaming.label", { ns: "components/dialog" })}
+
-
Restreaming is not enabled for this camera.
+
+ {t("streaming.restreaming.NotEnabled", {
+ ns: "components/dialog",
+ })}
+
- Info
+
+ {t("button.info", { ns: "common" })}
+
- Set up go2rtc for additional live view options and audio
- for this camera.
+ {t("streaming.restreaming.desc", {
+ ns: "components/dialog",
+ })}
- Read the documentation{" "}
+ {t("streaming.restreaming.readTheDocumentation", {
+ ns: "components/dialog",
+ })}
@@ -1213,7 +1281,9 @@ function FrigateCameraFeatures({
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
-
Stream
+
+ {t("stream.title")}
+
{
@@ -1248,22 +1318,23 @@ function FrigateCameraFeatures({
{supportsAudioOutput ? (
<>
- Audio is available for this stream
+ {t("stream.audio.available")}
>
) : (
<>
- Audio is unavailable for this stream
+ {t("stream.audio.unavailable")}
- Info
+
+ {t("button.info", { ns: "common" })}
+
- Audio must be output from your camera and
- configured in go2rtc for this stream.
+ {t("stream.audio.tips")}
- Read the documentation{" "}
+ {t("stream.audio.tips.documentation")}
@@ -1288,26 +1359,23 @@ function FrigateCameraFeatures({
{supports2WayTalk ? (
<>
-
- Two-way talk is available for this stream
-
+ {t("stream.twoWayTalk.available")}
>
) : (
<>
-
- Two-way talk is unavailable for this stream
-
+ {t("stream.twoWayTalk.available")}
- Info
+
+ {t("button.info", { ns: "common" })}
+
- Your device must suppport the feature and
- WebRTC must be configured for two-way talk.
+ {t("stream.twoWayTalk.tips")}
- Read the documentation{" "}
+ {t(
+ "stream.twoWayTalk.tips.documentation",
+ )}
@@ -1332,20 +1402,19 @@ function FrigateCameraFeatures({
- Live view is in low-bandwidth mode due to buffering
- or stream errors.
+ {t("stream.lowBandwidth.tips")}
setLowBandwidth(false)}
>
- Reset stream
+ {t("stream.lowBandwidth.resetStream")}
@@ -1359,7 +1428,7 @@ function FrigateCameraFeatures({
className="mx-0 cursor-pointer text-primary"
htmlFor="backgroundplay"
>
- Play in background
+ {t("stream.playInBackground.label")}
- Enable this option to continue streaming when the player is
- hidden.
+ {t("stream.playInBackground.tips")}
)}
@@ -1382,7 +1450,9 @@ function FrigateCameraFeatures({
className="mx-0 cursor-pointer text-primary"
htmlFor="showstats"
>
- Show stream stats
+ {t("streaming.showStats.label", {
+ ns: "components/dialog",
+ })}
- Enable this option to show stream statistics as an overlay on
- the camera feed.
+ {t("streaming.showStats.desc", { ns: "components/dialog" })}
- Debug View
+ {t("streaming.debugView", { ns: "components/dialog" })}
navigate(`/settings?page=debug&camera=${camera.name}`)
@@ -1427,7 +1496,7 @@ function FrigateCameraFeatures({
variant="primary"
Icon={FaCog}
isActive={false}
- title={`${camera} Settings`}
+ title={t("cameraSettings.title", { camera })}
/>
@@ -1435,14 +1504,14 @@ function FrigateCameraFeatures({
{isAdmin && (
<>
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
}
/>
sendDetect(detectState == "ON" ? "OFF" : "ON")
@@ -1450,7 +1519,7 @@ function FrigateCameraFeatures({
/>
{recordingEnabled && (
sendRecord(recordState == "ON" ? "OFF" : "ON")
@@ -1458,7 +1527,7 @@ function FrigateCameraFeatures({
/>
)}
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
@@ -1466,7 +1535,7 @@ function FrigateCameraFeatures({
/>
{audioDetectEnabled && (
sendAudio(audioState == "ON" ? "OFF" : "ON")
@@ -1475,7 +1544,7 @@ function FrigateCameraFeatures({
)}
{autotrackingEnabled && (
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
@@ -1489,20 +1558,27 @@ function FrigateCameraFeatures({
{!isRestreamed && (
-
Stream
+
{t("streaming", { ns: "components/dialog" })}
-
Restreaming is not enabled for this camera.
+
+ {t("streaming.restreaming.disabled", {
+ ns: "components/dialog",
+ })}
+
- Info
+
+ {t("button.info", { ns: "common" })}
+
- Set up go2rtc for additional live view options and audio for
- this camera.
+ {t("streaming.restreaming.desc", {
+ ns: "components/dialog",
+ })}
- Read the documentation{" "}
+ {t("streaming.restreaming.readTheDocumentation", {
+ ns: "components/dialog",
+ })}
@@ -1521,7 +1599,7 @@ function FrigateCameraFeatures({
)}
{isRestreamed && Object.values(camera.live.streams).length > 0 && (
-
Stream
+
{t("stream.title")}
{
@@ -1555,22 +1633,23 @@ function FrigateCameraFeatures({
{supportsAudioOutput ? (
<>
- Audio is available for this stream
+ {t("stream.audio.available")}
>
) : (
<>
- Audio is unavailable for this stream
+ {t("stream.audio.unavailable")}
- Info
+
+ {t("button.info", { ns: "common" })}
+
- Audio must be output from your camera and configured
- in go2rtc for this stream.
+ {t("stream.audio.tips")}
- Read the documentation{" "}
+ {t("stream.audio.tips.documentation")}
@@ -1595,22 +1674,23 @@ function FrigateCameraFeatures({
{supports2WayTalk ? (
<>
- Two-way talk is available for this stream
+ {t("stream.twoWayTalk.available")}
>
) : (
<>
- Two-way talk is unavailable for this stream
+ {t("stream.twoWayTalk.unavailable")}
- Info
+
+ {t("button.info", { ns: "common" })}
+
- Your device must suppport the feature and WebRTC
- must be configured for two-way talk.
+ {t("stream.twoWayTalk.tips")}
- Read the documentation{" "}
+ {t("stream.twoWayTalk.tips.documentation")}
@@ -1633,10 +1713,7 @@ function FrigateCameraFeatures({
-
- Live view is in low-bandwidth mode due to buffering or
- stream errors.
-
+
{t("stream.lowBandwidth.tips")}
setLowBandwidth(false)}
>
- Reset stream
+
+ {t("stream.lowBandwidth.resetStream")}
+
)}
@@ -1654,7 +1733,7 @@ function FrigateCameraFeatures({
)}
- On-Demand Recording
+ {t("manualRecording.title")}
- {isRecording ? "End" : "Start"} on-demand recording
+ {t("manualRecording." + isRecording ? "end" : "start")}
- Start a manual event based on this camera's recording retention
- settings.
+ {t("manualRecording.tips")}
{isRestreamed && (
<>
{
setPlayInBackground(checked);
}}
/>
- Enable this option to continue streaming when the player is
- hidden.
+ {t("manualRecording.playInBackground.desc")}
{
setShowStats(checked);
}}
/>
- Enable this option to show stream statistics as an overlay on
- the camera feed.
+ {t("manualRecording.showStats.desc")}
>
)}
- Debug View
+ {t("manualRecording.debugView")}
navigate(`/settings?page=debug&camera=${camera.name}`)
diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx
index e59fd96ca..199146b0b 100644
--- a/web/src/views/live/LiveDashboardView.tsx
+++ b/web/src/views/live/LiveDashboardView.tsx
@@ -43,6 +43,7 @@ import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { useResizeObserver } from "@/hooks/resize-observer";
import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
+import { useTranslation } from "react-i18next";
type LiveDashboardViewProps = {
cameras: CameraConfig[];
@@ -60,6 +61,8 @@ export default function LiveDashboardView({
fullscreen,
toggleFullscreen,
}: LiveDashboardViewProps) {
+ const { t } = useTranslation(["views/live"]);
+
const { data: config } = useSWR("config");
// layout
@@ -471,7 +474,9 @@ export default function LiveDashboardView({
}
const streamName =
currentGroupStreamingSettings?.[camera.name]?.streamName ||
- Object.values(camera.live.streams)?.[0];
+ camera?.live?.streams
+ ? Object?.values(camera?.live?.streams)?.[0]
+ : "";
const autoLive =
currentGroupStreamingSettings?.[camera.name]?.streamType !==
"no-streaming";
@@ -560,7 +565,9 @@ export default function LiveDashboardView({
- {fullscreen ? "Exit Fullscreen" : "Fullscreen"}
+ {fullscreen
+ ? t("button.exitFullscreen", { ns: "common" })
+ : t("button.fullscreen", { ns: "common" })}
diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx
index 0baeca994..84db0185d 100644
--- a/web/src/views/recording/RecordingView.tsx
+++ b/web/src/views/recording/RecordingView.tsx
@@ -49,6 +49,7 @@ import { cn } from "@/lib/utils";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { useTimezone } from "@/hooks/use-date-utils";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
+import { useTranslation } from "react-i18next";
type RecordingViewProps = {
startCamera: string;
@@ -72,6 +73,7 @@ export function RecordingView({
filter,
updateFilter,
}: RecordingViewProps) {
+ const { t } = useTranslation(["views/events"]);
const { data: config } = useSWR("config");
const navigate = useNavigate();
const contentRef = useRef(null);
@@ -393,12 +395,16 @@ export function RecordingView({
navigate(-1)}
>
- {isDesktop && Back
}
+ {isDesktop && (
+
+ {t("button.back", { ns: "common" })}
+
+ )}
- {isDesktop && Live
}
+ {isDesktop && (
+
+ {t("menu.live", { ns: "common" })}
+
+ )}
@@ -482,16 +492,16 @@ export function RecordingView({
- Timeline
+ {t("timeline")}
- Events
+ {t("events.label")}
) : (
@@ -689,6 +699,7 @@ function Timeline({
setScrubbing,
setExportRange,
}: TimelineProps) {
+ const { t } = useTranslation(["views/events"]);
const internalTimelineRef = useRef
(null);
const selectedTimelineRef = timelineRef || internalTimelineRef;
@@ -798,7 +809,7 @@ function Timeline({
>
{mainCameraReviewItems.length === 0 ? (
- No events found for this time period.
+ {t("events.noFoundForTimePeriod")}
) : (
mainCameraReviewItems.map((review) => {
diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx
index a8d241c00..96a7d7be6 100644
--- a/web/src/views/search/SearchView.tsx
+++ b/web/src/views/search/SearchView.tsx
@@ -22,7 +22,7 @@ import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { isEqual } from "lodash";
import { formatDateToLocaleString } from "@/utils/dateUtil";
import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter";
-import SearchSettings from "@/components/settings/SearchSettings";
+import ExploreSettings from "@/components/settings/SearchSettings";
import {
Tooltip,
TooltipContent,
@@ -31,6 +31,7 @@ import {
import Chip from "@/components/indicators/Chip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import SearchActionGroup from "@/components/filter/SearchActionGroup";
+import { useTranslation } from "react-i18next";
type SearchViewProps = {
search: string;
@@ -70,6 +71,7 @@ export default function SearchView({
setColumns,
setDefaultView,
}: SearchViewProps) {
+ const { t } = useTranslation(["views/explore"]);
const contentRef = useRef(null);
const { data: config } = useSWR("config", {
revalidateOnFocus: false,
@@ -495,7 +497,7 @@ export default function SearchView({
filter={searchFilter}
onUpdateFilter={onUpdateFilter}
/>
-
- No Tracked Objects Found
+ {t("noTrackedObjects")}
)}
@@ -616,7 +618,7 @@ export default function SearchView({
}}
refreshResults={refresh}
showObjectLifecycle={() =>
- onSelectSearch(value, false, "object lifecycle")
+ onSelectSearch(value, false, "object_lifecycle")
}
showSnapshot={() =>
onSelectSearch(value, false, "snapshot")
diff --git a/web/src/views/settings/AuthenticationView.tsx b/web/src/views/settings/AuthenticationView.tsx
index 118d102d4..f84b34bbc 100644
--- a/web/src/views/settings/AuthenticationView.tsx
+++ b/web/src/views/settings/AuthenticationView.tsx
@@ -13,6 +13,7 @@ import { toast } from "sonner";
import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
import { HiTrash } from "react-icons/hi";
import { FaUserEdit } from "react-icons/fa";
+
import { LuPlus, LuShield, LuUserCog } from "react-icons/lu";
import {
Table,
@@ -30,8 +31,10 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import RoleChangeDialog from "@/components/overlay/RoleChangeDialog";
+import { useTranslation } from "react-i18next";
export default function AuthenticationView() {
+ const { t } = useTranslation("views/settings");
const { data: config } = useSWR("config");
const { data: users, mutate: mutateUsers } = useSWR("users");
@@ -46,30 +49,38 @@ export default function AuthenticationView() {
>();
useEffect(() => {
- document.title = "Authentication Settings - Frigate";
- }, []);
+ document.title = t("documentTitle.authentication");
+ }, [t]);
- const onSavePassword = useCallback((user: string, password: string) => {
- axios
- .put(`users/${user}/password`, { password })
- .then((response) => {
- if (response.status === 200) {
- setShowSetPassword(false);
- toast.success("Password updated successfully", {
- position: "top-center",
- });
- }
- })
- .catch((error) => {
- const errorMessage =
- error.response?.data?.message ||
- error.response?.data?.detail ||
- "Unknown error";
- toast.error(`Failed to save password: ${errorMessage}`, {
- position: "top-center",
+ const onSavePassword = useCallback(
+ (user: string, password: string) => {
+ axios
+ .put(`users/${user}/password`, { password })
+ .then((response) => {
+ if (response.status === 200) {
+ setShowSetPassword(false);
+ toast.success(t("users.toast.success.updatePassword"), {
+ position: "top-center",
+ });
+ }
+ })
+ .catch((error) => {
+ const errorMessage =
+ error.response?.data?.message ||
+ error.response?.data?.detail ||
+ "Unknown error";
+ toast.error(
+ t("users.toast.error.setPasswordFailed", {
+ errorMessage,
+ }),
+ {
+ position: "top-center",
+ },
+ );
});
- });
- }, []);
+ },
+ [t],
+ );
const onCreate = (
user: string,
@@ -85,7 +96,7 @@ export default function AuthenticationView() {
users?.push({ username: user, role: role });
return users;
}, false);
- toast.success(`User ${user} created successfully`, {
+ toast.success(t("users.toast.success.createUser", { user }), {
position: "top-center",
});
}
@@ -95,9 +106,14 @@ export default function AuthenticationView() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to create user: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("users.toast.error.createUserFailed", {
+ errorMessage,
+ }),
+ {
+ position: "top-center",
+ },
+ );
});
};
@@ -111,7 +127,7 @@ export default function AuthenticationView() {
(users) => users?.filter((u) => u.username !== user),
false,
);
- toast.success(`User ${user} deleted successfully`, {
+ toast.success(t("users.toast.success.deleteUser", { user }), {
position: "top-center",
});
}
@@ -121,9 +137,14 @@ export default function AuthenticationView() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to delete user: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("users.toast.error.deleteUserFailed", {
+ errorMessage,
+ }),
+ {
+ position: "top-center",
+ },
+ );
});
};
@@ -142,7 +163,7 @@ export default function AuthenticationView() {
),
false,
);
- toast.success(`Role updated for ${user}`, {
+ toast.success(t("users.toast.success.roleUpdated", { user }), {
position: "top-center",
});
}
@@ -152,9 +173,14 @@ export default function AuthenticationView() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to update role: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("users.toast.error.roleUpdateFailed", {
+ errorMessage,
+ }),
+ {
+ position: "top-center",
+ },
+ );
});
};
@@ -173,20 +199,20 @@ export default function AuthenticationView() {
- User Management
+ {t("users.management")}
- Manage this Frigate instance's user accounts.
+ {t("users.management.desc")}
setShowCreate(true)}
>
- Add User
+ {t("users.addUser")}
@@ -195,16 +221,20 @@ export default function AuthenticationView() {
- Username
- Role
- Actions
+
+ {t("users.table.username")}
+
+ {t("users.table.role")}
+
+ {t("users.table.actions")}
+
{users.length === 0 ? (
- No users found.
+ {t("users.table.noUsers")}
) : (
@@ -231,7 +261,9 @@ export default function AuthenticationView() {
: ""
}
>
- {user.role || "viewer"}
+ {t("role." + (user.role || "viewer"), {
+ ns: "common",
+ })}
@@ -255,12 +287,12 @@ export default function AuthenticationView() {
>
- Role
+ {t("role.title", { ns: "common" })}
- Change user role
+ {t("users.table.changeRole")}
)}
@@ -278,12 +310,12 @@ export default function AuthenticationView() {
>
- Password
+ {t("users.table.password")}
- Update password
+ {t("users.updatePassword")}
@@ -301,12 +333,12 @@ export default function AuthenticationView() {
>
- Delete
+ {t("button.delete", { ns: "common" })}
- Delete user
+ {t("users.table.deleteUser")}
)}
diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx
index f83bdde50..19381684b 100644
--- a/web/src/views/settings/CameraSettingsView.tsx
+++ b/web/src/views/settings/CameraSettingsView.tsx
@@ -27,6 +27,7 @@ import { LuExternalLink } from "react-icons/lu";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { MdCircle } from "react-icons/md";
import { cn } from "@/lib/utils";
+import { Trans, useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws";
@@ -45,6 +46,8 @@ export default function CameraSettingsView({
selectedCamera,
setUnsavedChanges,
}: CameraSettingsViewProps) {
+ const { t } = useTranslation(["views/settings"]);
+
const { data: config, mutate: updateConfig } =
useSWR("config");
@@ -76,18 +79,18 @@ export default function CameraSettingsView({
const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels
? cameraConfig.review.alerts.labels
- .map((label) => label.replaceAll("_", " "))
+ .map((label) => t(label, { ns: "objects" }))
.join(", ")
: "";
- }, [cameraConfig]);
+ }, [cameraConfig, t]);
const detectionsLabels = useMemo(() => {
return cameraConfig?.review.detections.labels
? cameraConfig.review.detections.labels
- .map((label) => label.replaceAll("_", " "))
+ .map((label) => t(label, { ns: "objects" }))
.join(", ")
: "";
- }, [cameraConfig]);
+ }, [cameraConfig, t]);
// form
@@ -157,17 +160,20 @@ export default function CameraSettingsView({
})
.then((res) => {
if (res.status === 200) {
- toast.success(
- `Review classification configuration has been saved. Restart Frigate to apply changes.`,
+ toast.success(t("camera.reviewClassification.toast.success"), {
+ position: "top-center",
+ });
+ updateConfig();
+ } else {
+ toast.error(
+ t("toast.save.error", {
+ errorMessage: res.statusText,
+ ns: "common",
+ }),
{
position: "top-center",
},
);
- updateConfig();
- } else {
- toast.error(`Failed to save config changes: ${res.statusText}`, {
- position: "top-center",
- });
}
})
.catch((error) => {
@@ -175,15 +181,21 @@ export default function CameraSettingsView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to save config changes: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage,
+ ns: "common",
+ }),
+ {
+ position: "top-center",
+ },
+ );
})
.finally(() => {
setIsLoading(false);
});
},
- [updateConfig, setIsLoading, selectedCamera, cameraConfig],
+ [updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
);
const onCancel = useCallback(() => {
@@ -252,13 +264,13 @@ export default function CameraSettingsView({
- Camera Settings
+ camera.title
- Streams
+ camera.streams.title
@@ -271,19 +283,18 @@ export default function CameraSettingsView({
}}
/>
- Enable
+
+ button.enabled
+
- Disabling a camera completely stops Frigate's processing of this
- camera's streams. Detection, recording, and debugging will be
- unavailable.
- Note: This does not disable go2rtc restreams.
+ camera.streams.desc
- Review
+ camera.review.title
@@ -297,7 +308,9 @@ export default function CameraSettingsView({
}}
/>
- Alerts
+
+ camera.review.alerts
+
@@ -311,12 +324,13 @@ export default function CameraSettingsView({
}}
/>
- Detections
+
+ camera.review.detections
+
- Enable/disable alerts and detections for this camera. When
- disabled, no new review items will be generated.
+ camera.review.desc
@@ -324,16 +338,15 @@ export default function CameraSettingsView({
- Review Classification
+ camera.reviewClassification.title
- Frigate categorizes review items as Alerts and Detections. By
- default, all person and car objects are
- considered Alerts. You can refine categorization of your review
- items by configuring required zones for them.
+
+ camera.reviewClassification.desc
+
- Read the Documentation{" "}
+
+ camera.reviewClassification.readTheDocumentation
+ {" "}
@@ -371,11 +386,15 @@ export default function CameraSettingsView({
<>
- Alerts{" "}
+
+ camera.review.alerts
+
- Select zones for Alerts
+
+ camera.reviewClassification.selectAlertsZones
+
@@ -424,20 +443,37 @@ export default function CameraSettingsView({
>
) : (
- No zones are defined for this camera.
+
+ camera.reviewClassification.noDefinedZones
+
)}
- All {alertsLabels} objects
{watchedAlertsZones && watchedAlertsZones.length > 0
- ? ` detected in ${watchedAlertsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}`
- : ""}{" "}
- on{" "}
- {capitalizeFirstLetter(
- cameraConfig?.name ?? "",
- ).replaceAll("_", " ")}{" "}
- will be shown as Alerts.
+ ? t(
+ "camera.reviewClassification.zoneObjectAlertsTips",
+ {
+ alertsLabels,
+ zone: watchedAlertsZones
+ .map((zone) =>
+ capitalizeFirstLetter(zone).replaceAll(
+ "_",
+ " ",
+ ),
+ )
+ .join(", "),
+ cameraName: capitalizeFirstLetter(
+ cameraConfig?.name ?? "",
+ ).replaceAll("_", " "),
+ },
+ )
+ : t("camera.reviewClassification.objectAlertsTips", {
+ alertsLabels,
+ cameraName: capitalizeFirstLetter(
+ cameraConfig?.name ?? "",
+ ).replaceAll("_", " "),
+ })}
)}
@@ -452,12 +488,16 @@ export default function CameraSettingsView({
<>
- Detections{" "}
+
+ camera.review.detections
+
{selectDetections && (
- Select zones for Detections
+
+ camera.reviewClassification.selectDetectionsZones
+
)}
@@ -520,7 +560,9 @@ export default function CameraSettingsView({
htmlFor="select-detections"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
- Limit detections to specific zones
+
+ camera.reviewClassification.limitDetections
+
@@ -528,22 +570,59 @@ export default function CameraSettingsView({
)}
- All {detectionsLabels} objects{" "}
- not classified as Alerts {" "}
{watchedDetectionsZones &&
- watchedDetectionsZones.length > 0
- ? ` that are detected in ${watchedDetectionsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}`
- : ""}{" "}
- on{" "}
- {capitalizeFirstLetter(
- cameraConfig?.name ?? "",
- ).replaceAll("_", " ")}{" "}
- will be shown as Detections
- {(!selectDetections ||
- (watchedDetectionsZones &&
- watchedDetectionsZones.length === 0)) &&
- ", regardless of zone"}
- .
+ watchedDetectionsZones.length > 0 ? (
+ !selectDetections ? (
+
+ capitalizeFirstLetter(zone).replaceAll(
+ "_",
+ " ",
+ ),
+ )
+ .join(", "),
+ cameraName: capitalizeFirstLetter(
+ cameraConfig?.name ?? "",
+ ).replaceAll("_", " "),
+ }}
+ ns="views/settings"
+ >
+ ) : (
+
+ capitalizeFirstLetter(zone).replaceAll(
+ "_",
+ " ",
+ ),
+ )
+ .join(", "),
+ cameraName: capitalizeFirstLetter(
+ cameraConfig?.name ?? "",
+ ).replaceAll("_", " "),
+ }}
+ ns="views/settings"
+ />
+ )
+ ) : (
+
+ )}
)}
@@ -554,26 +633,28 @@ export default function CameraSettingsView({
- Cancel
+ button.cancel
{isLoading ? (
-
Saving...
+
+ button.saving
+
) : (
- "Save"
+ button.save
)}
diff --git a/web/src/views/settings/ClassificationSettingsView.tsx b/web/src/views/settings/ClassificationSettingsView.tsx
index f6ce3c37d..e93677833 100644
--- a/web/src/views/settings/ClassificationSettingsView.tsx
+++ b/web/src/views/settings/ClassificationSettingsView.tsx
@@ -20,6 +20,7 @@ import {
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
+import { Trans, useTranslation } from "react-i18next";
type ClassificationSettings = {
search: {
@@ -41,6 +42,7 @@ type ClassificationSettingsViewProps = {
export default function ClassificationSettingsView({
setUnsavedChanges,
}: ClassificationSettingsViewProps) {
+ const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
useSWR
("config");
const [changedValue, setChangedValue] = useState(false);
@@ -141,15 +143,18 @@ export default function ClassificationSettingsView({
)
.then((res) => {
if (res.status === 200) {
- toast.success("Classification settings have been saved.", {
+ toast.success(t("classification.toast.success"), {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
- toast.error(`Failed to save config changes: ${res.statusText}`, {
- position: "top-center",
- });
+ toast.error(
+ t("classification.toast.error", { errorMessage: res.statusText }),
+ {
+ position: "top-center",
+ },
+ );
}
})
.catch((error) => {
@@ -157,14 +162,14 @@ export default function ClassificationSettingsView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to save config changes: ${errorMessage}`, {
+ toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
})
.finally(() => {
setIsLoading(false);
});
- }, [updateConfig, classificationSettings.search]);
+ }, [updateConfig, classificationSettings.search, t]);
const onCancel = useCallback(() => {
setClassificationSettings(origSearchSettings);
@@ -188,8 +193,8 @@ export default function ClassificationSettingsView({
}, [changedValue]);
useEffect(() => {
- document.title = "Classification Settings - Frigate";
- }, []);
+ document.title = t("documentTitle.classification");
+ }, [t]);
if (!config) {
return ;
@@ -200,19 +205,15 @@ export default function ClassificationSettingsView({
- Classification Settings
+ {t("classification.title")}
- Semantic Search
+ {t("classification.semanticSearch.title")}
-
- Semantic Search in Frigate allows you to find tracked objects
- within your review items using either the image itself, a
- user-defined text description, or an automatically generated one.
-
+
{t("classification.semanticSearch.desc")}
- Read the Documentation
+ {t("classification.semanticSearch.readTheDocumentation")}
@@ -242,7 +243,9 @@ export default function ClassificationSettingsView({
}}
/>
- Enabled
+
+ {t("button.enabled", { ns: "common" })}
+
@@ -259,31 +262,38 @@ export default function ClassificationSettingsView({
}}
/>
- Re-Index On Startup
+
+ {t("classification.semanticSearch.reindexOnStartup.label")}
+
- Re-indexing will reprocess all thumbnails and descriptions (if
- enabled) and apply the embeddings on each startup.{" "}
- Don't forget to disable the option after restarting!
+
+ classification.semanticSearch.reindexOnStartup.desc
+
-
Model Size
+
+ {t("classification.semanticSearch.modelSize.label")}
+
- The size of the model used for Semantic Search embeddings.
+
+ classification.semanticSearch.modelSize.desc
+
- Using small employs a quantized version of the
- model that uses less RAM and runs faster on CPU with a very
- negligible difference in embedding quality.
+
+ classification.semanticSearch.modelSize.small.desc
+
- Using large employs the full Jina model and will
- automatically run on the GPU if applicable.
+
+ classification.semanticSearch.modelSize.large.desc
+
@@ -309,7 +319,7 @@ export default function ClassificationSettingsView({
className="cursor-pointer"
value={size}
>
- {size}
+ {t("classification.semanticSearch.modelSize." + size)}
))}
@@ -322,16 +332,11 @@ export default function ClassificationSettingsView({
- Face Recognition
+ {t("classification.faceRecognition.title")}
-
- Face recognition allows people to be assigned names and when
- their face is recognized Frigate will assign the person's name
- as a sub label. This information is included in the UI, filters,
- as well as in notifications.
-
+
{t("classification.faceRecognition.desc")}
- Read the Documentation
+ {t("classification.faceRecognition.readTheDocumentation")}
@@ -361,7 +366,9 @@ export default function ClassificationSettingsView({
}}
/>
- Enabled
+
+ {t("button.enabled", { ns: "common" })}
+
@@ -369,18 +376,11 @@ export default function ClassificationSettingsView({
- License Plate Recognition
+ {t("classification.licensePlateRecognition.title")}
-
- Frigate can recognize license plates on vehicles and
- automatically add the detected characters to the
- recognized_license_plate field or a known name as a sub_label to
- objects that are of type car. A common use case may be to read
- the license plates of cars pulling into a driveway or cars
- passing by on a street.
-
+
{t("classification.licensePlateRecognition.desc")}
- Read the Documentation
+ {t(
+ "classification.licensePlateRecognition.readTheDocumentation",
+ )}
@@ -410,7 +412,9 @@ export default function ClassificationSettingsView({
}}
/>
- Enabled
+
+ {t("button.enabled", { ns: "common" })}
+
@@ -420,10 +424,10 @@ export default function ClassificationSettingsView({
- Reset
+ {t("button.reset", { ns: "common" })}
- Saving...
+ {t("button.saving", { ns: "common" })}
) : (
- "Save"
+ t("button.save", { ns: "common" })
)}
diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx
index 27e495766..d30901695 100644
--- a/web/src/views/settings/MasksAndZonesView.tsx
+++ b/web/src/views/settings/MasksAndZonesView.tsx
@@ -37,7 +37,9 @@ import PolygonItem from "@/components/settings/PolygonItem";
import { Link } from "react-router-dom";
import { isDesktop } from "react-device-detect";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
+
import { useSearchEffect } from "@/hooks/use-overlay-state";
+import { useTranslation } from "react-i18next";
type MasksAndZoneViewProps = {
selectedCamera: string;
@@ -50,6 +52,7 @@ export default function MasksAndZonesView({
selectedZoneMask,
setUnsavedChanges,
}: MasksAndZoneViewProps) {
+ const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR
("config");
const [allPolygons, setAllPolygons] = useState([]);
const [editingPolygons, setEditingPolygons] = useState([]);
@@ -182,8 +185,8 @@ export default function MasksAndZonesView({
setActivePolygonIndex(undefined);
setHoveredPolygonIndex(null);
setUnsavedChanges(false);
- document.title = "Mask and Zone Editor - Frigate";
- }, [allPolygons, setUnsavedChanges]);
+ document.title = t("documentTitle.masksAndZones");
+ }, [allPolygons, setUnsavedChanges, t]);
const handleSave = useCallback(() => {
setAllPolygons([...(editingPolygons ?? [])]);
@@ -218,12 +221,16 @@ export default function MasksAndZonesView({
.map((point) => `${point[0]},${point[1]}`)
.join(","),
);
- toast.success(`Copied coordinates for ${poly.name} to clipboard.`);
+ toast.success(
+ t("masksAndZones.toast.success.copyCoordinates", {
+ polyName: poly.name,
+ }),
+ );
} else {
- toast.error("Could not copy coordinates to clipboard.");
+ toast.error(t("masksAndZones.toast.error.copyCoordinatesFailed"));
}
},
- [allPolygons, scaledHeight, scaledWidth],
+ [allPolygons, scaledHeight, scaledWidth, t],
);
useEffect(() => {
@@ -418,8 +425,8 @@ export default function MasksAndZonesView({
});
useEffect(() => {
- document.title = "Mask and Zone Editor - Frigate";
- }, []);
+ document.title = t("documentTitle.masksAndZones");
+ }, [t]);
if (!cameraConfig && !selectedCamera) {
return ;
@@ -480,7 +487,7 @@ export default function MasksAndZonesView({
{editPane === undefined && (
<>
- Masks / Zones
+ {t("menu.masksAndZones")}
{(selectedZoneMask === undefined ||
@@ -489,15 +496,13 @@ export default function MasksAndZonesView({
- Zones
+
+ {t("masksAndZones.zones.label")}
+
-
- 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.
-
+
{t("masksAndZones.zones.desc")}
- Documentation{" "}
+ {t("masksAndZones.zones.desc.documentation")}{" "}
@@ -517,7 +522,7 @@ export default function MasksAndZonesView({
{
setEditPane("zone");
handleNewPolygon("zone");
@@ -526,7 +531,9 @@ export default function MasksAndZonesView({
-
Add Zone
+
+ {t("masksAndZones.zones.add")}
+
{allPolygons
@@ -556,17 +563,12 @@ export default function MasksAndZonesView({
- Motion Masks
+ {t("masksAndZones.motionMasks.label")}
-
- 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.
-
+
{t("masksAndZones.motionMasks.desc")}
- Documentation{" "}
+ {t(
+ "masksAndZones.motionMasks.desc.documentation",
+ )}{" "}
@@ -586,7 +590,7 @@ export default function MasksAndZonesView({
{
setEditPane("motion_mask");
handleNewPolygon("motion_mask");
@@ -595,7 +599,9 @@ export default function MasksAndZonesView({
-
Add Motion Mask
+
+ {t("masksAndZones.motionMasks.add")}
+
{allPolygons
@@ -627,16 +633,12 @@ export default function MasksAndZonesView({
- Object Masks
+ {t("masksAndZones.objectMasks.label")}
-
- Object filter masks are used to filter out false
- positives for a given object type based on
- location.
-
+
{t("masksAndZones.objectMasks.desc")}
- Documentation{" "}
+ {t("masksAndZones.objectMasks.documentation")}{" "}
@@ -656,7 +658,7 @@ export default function MasksAndZonesView({
{
setEditPane("object_mask");
handleNewPolygon("object_mask");
@@ -665,7 +667,9 @@ export default function MasksAndZonesView({
-
Add Object Mask
+
+ {t("masksAndZones.objectMasks.add")}
+
{allPolygons
diff --git a/web/src/views/settings/MotionTunerView.tsx b/web/src/views/settings/MotionTunerView.tsx
index 6ccdfbf27..d3f914df3 100644
--- a/web/src/views/settings/MotionTunerView.tsx
+++ b/web/src/views/settings/MotionTunerView.tsx
@@ -21,6 +21,7 @@ import { Separator } from "@/components/ui/separator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
+import { Trans, useTranslation } from "react-i18next";
type MotionTunerViewProps = {
selectedCamera: string;
@@ -37,6 +38,7 @@ export default function MotionTunerView({
selectedCamera,
setUnsavedChanges,
}: MotionTunerViewProps) {
+ const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR("config");
const [changedValue, setChangedValue] = useState(false);
@@ -117,20 +119,29 @@ export default function MotionTunerView({
)
.then((res) => {
if (res.status === 200) {
- toast.success("Motion settings have been saved.", {
+ toast.success(t("motionDetectionTuner.toast.success"), {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
- toast.error(`Failed to save config changes: ${res.statusText}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage: res.statusText,
+ ns: "common",
+ }),
+ {
+ position: "top-center",
+ },
+ );
}
})
.catch((error) => {
toast.error(
- `Failed to save config changes: ${error.response.data.message}`,
+ t("toast.save.error", {
+ errorMessage: error.response.data.message,
+ ns: "common",
+ }),
{ position: "top-center" },
);
})
@@ -143,6 +154,7 @@ export default function MotionTunerView({
motionSettings.contour_area,
motionSettings.improve_contrast,
selectedCamera,
+ t,
]);
const onCancel = useCallback(() => {
@@ -167,8 +179,8 @@ export default function MotionTunerView({
}, [changedValue, selectedCamera]);
useEffect(() => {
- document.title = "Motion Tuner - Frigate";
- }, []);
+ document.title = t("documentTitle.motionTuner");
+ }, [t]);
if (!cameraConfig && !selectedCamera) {
return ;
@@ -179,14 +191,10 @@ export default function MotionTunerView({
- Motion Detection Tuner
+ {t("motionDetectionTuner.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.
-
+
{t("motionDetectionTuner.desc")}
- Read the Motion Tuning Guide{" "}
+ {t("motionDetectionTuner.desc.documentation")}{" "}
@@ -205,14 +213,12 @@ export default function MotionTunerView({
- Threshold
+ {t("motionDetectionTuner.Threshold")}
-
- The threshold value dictates how much of a change in a pixel's
- luminance is required to be considered motion.{" "}
- Default: 30
-
+
+ motionDetectionTuner.Threshold.desc
+
@@ -236,12 +242,13 @@ export default function MotionTunerView({
- Contour Area
+ {t("motionDetectionTuner.contourArea")}
- The contour area value is used to decide which groups of
- changed pixels qualify as motion. Default: 10
+
+ motionDetectionTuner.contourArea.desc
+
@@ -266,9 +273,13 @@ export default function MotionTunerView({
-
Improve Contrast
+
+ {t("motionDetectionTuner.improveContrast")}
+
- Improve contrast for darker scenes. Default: ON
+
+ motionDetectionTuner.improveContrast.desc
+
- Reset
+ {t("button.reset", { ns: "common" })}
{isLoading ? (
-
Saving...
+
{t("button.saving", { ns: "common" })}
) : (
- "Save"
+ t("button.save", { ns: "common" })
)}
diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx
index a7dd1c9d4..039e342cb 100644
--- a/web/src/views/settings/NotificationsSettingsView.tsx
+++ b/web/src/views/settings/NotificationsSettingsView.tsx
@@ -18,8 +18,10 @@ import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
+
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
+
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { CiCircleAlert } from "react-icons/ci";
import { Link } from "react-router-dom";
@@ -41,6 +43,7 @@ import {
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Trans, useTranslation } from "react-i18next";
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
@@ -56,6 +59,8 @@ type NotificationsSettingsViewProps = {
export default function NotificationView({
setUnsavedChanges,
}: NotificationsSettingsViewProps) {
+ const { t } = useTranslation(["views/settings"]);
+
const { data: config, mutate: updateConfig } = useSWR
(
"config",
{
@@ -138,23 +143,20 @@ export default function NotificationView({
sub: pushSubscription,
})
.catch(() => {
- toast.error("Failed to save notification registration.", {
+ toast.error(t("notification.toast.error.registerFailed"), {
position: "top-center",
});
pushSubscription.unsubscribe();
registration.unregister();
setRegistration(null);
});
- toast.success(
- "Successfully registered for notifications. Restarting Frigate is required before any notifications (including a test notification) can be sent.",
- {
- position: "top-center",
- },
- );
+ toast.success(t("notification.toast.success.registered"), {
+ position: "top-center",
+ });
});
}
},
- [publicKey, addMessage],
+ [publicKey, addMessage, t],
);
// notification state
@@ -256,14 +258,20 @@ export default function NotificationView({
)
.then((res) => {
if (res.status === 200) {
- toast.success("Notification settings have been saved.", {
+ toast.success(t("notification.toast.success.settingSaved"), {
position: "top-center",
});
updateConfig();
} else {
- toast.error(`Failed to save config changes: ${res.statusText}`, {
- position: "top-center",
- });
+ toast.error(
+ t("toast.save.error", {
+ errorMessage: res.statusText,
+ ns: "common",
+ }),
+ {
+ position: "top-center",
+ },
+ );
}
})
.catch((error) => {
@@ -271,7 +279,7 @@ export default function NotificationView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to save config changes: ${errorMessage}`, {
+ toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
})
@@ -279,7 +287,7 @@ export default function NotificationView({
setIsLoading(false);
});
},
- [updateConfig, setIsLoading, allCameras],
+ [updateConfig, setIsLoading, allCameras, t],
);
function onSubmit(values: z.infer) {
@@ -293,14 +301,11 @@ export default function NotificationView({
- Notification Settings
+ {t("notification.notificationSettings.title")}
-
- Frigate can natively send push notifications to your device
- when it is running in the browser or installed as a PWA.
-
+
{t("notification.notificationSettings.desc")}
- Read the Documentation{" "}
+
+ {t("notification.notificationSettings.documentation")}
+
{" "}
@@ -316,12 +323,13 @@ export default function NotificationView({
- Notifications Unavailable
-
+
+ {t("notification.notificationUnavailable.title")}
+
- Web push notifications require a secure context (
- https://...
). This is a browser limitation. Access
- Frigate securely to use notifications.
+
+ notification.notificationUnavailable.desc
+
- Read the Documentation{" "}
+
+ {t("notification.notificationUnavailable.documentation")}
+
{" "}
@@ -349,15 +359,12 @@ export default function NotificationView({
- Notification Settings
+ {t("notification.notificationSettings.title")}
-
- Frigate can natively send push notifications to your device
- when it is running in the browser or installed as a PWA.
-
+
{t("notification.notificationSettings.desc")}
- Read the Documentation{" "}
+ {t("notification.notificationSettings.documentation")}{" "}
@@ -382,17 +389,16 @@ export default function NotificationView({
name="email"
render={({ field }) => (
- Email
+ {t("notification.email")}
- Entering a valid email is required, as this is used by
- the push server in case problems occur.
+ {t("notification.email.desc")}
@@ -408,7 +414,7 @@ export default function NotificationView({
<>
- Cameras
+ {t("notification.cameras")}
@@ -417,7 +423,9 @@ export default function NotificationView({
name="allEnabled"
render={({ field }) => (
{
setChangedValue(true);
@@ -456,13 +464,13 @@ export default function NotificationView({
>
) : (
- No cameras available.
+ {t("notification.cameras.noCameras")}
)}
- Select the cameras to enable notifications for.
+ {t("notification.cameras.desc")}
)}
@@ -471,26 +479,26 @@ export default function NotificationView({
- Cancel
+ {t("button.cancel", { ns: "common" })}
{isLoading ? (
-
Saving...
+
{t("button.saving", { ns: "common" })}
) : (
- "Save"
+ t("button.save", { ns: "common" })
)}
@@ -503,7 +511,7 @@ export default function NotificationView({
- Device-Specific Settings
+ {t("notification.deviceSpecific")}
- {`${registration != null ? "Unregister" : "Register"} this device`}
+ {registration != null
+ ? t("notification.unregisterDevice")
+ : t("notification.registerDevice")}
{registration != null && registration.active && (
sendTestNotification("notification_test")}
>
- Send a test notification
+ {t("notification.sendTestNotification")}
)}
@@ -563,14 +573,11 @@ export default function NotificationView({
- Global Settings
+ {t("notification.globalSettings.title")}
-
- Temporarily suspend notifications for specific cameras
- on all registered devices.
-
+
{t("notification.globalSettings.desc")}
@@ -606,6 +613,7 @@ export function CameraNotificationSwitch({
config,
camera,
}: CameraNotificationSwitchProps) {
+ const { t } = useTranslation(["views/settings"]);
const { payload: notificationState, send: sendNotification } =
useNotifications(camera);
const { payload: notificationSuspendUntil, send: sendNotificationSuspend } =
@@ -635,14 +643,19 @@ export function CameraNotificationSwitch({
};
const formatSuspendedUntil = (timestamp: string) => {
- if (timestamp === "0") return "Frigate restarts.";
+ // Some languages require a change in word order
+ if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
- return formatUnixTimestampToDateTime(parseInt(timestamp), {
+ const time = formatUnixTimestampToDateTime(parseInt(timestamp), {
time_style: "medium",
date_style: "medium",
timezone: config?.ui.timezone,
- strftime_fmt: `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`,
+ strftime_fmt:
+ config?.ui.time_format == "24hour"
+ ? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
+ : t("time.formattedTimestampExcludeSeconds", { ns: "common" }),
});
+ return t("time.untilForTime", { ns: "common", time });
};
return (
@@ -664,12 +677,13 @@ export function CameraNotificationSwitch({
{!isSuspended ? (
- Notifications Active
+ {t("notification.active")}
) : (
- Notifications suspended until{" "}
- {formatSuspendedUntil(notificationSuspendUntil)}
+ {t("notification.suspended", {
+ time: formatSuspendedUntil(notificationSuspendUntil),
+ })}
)}
@@ -682,13 +696,27 @@ export function CameraNotificationSwitch({
- Suspend for 5 minutes
- Suspend for 10 minutes
- Suspend for 30 minutes
- Suspend for 1 hour
- Suspend for 12 hours
- Suspend for 24 hours
- Suspend until restart
+
+ {t("notification.suspendTime.5minutes")}
+
+
+ {t("notification.suspendTime.10minutes")}
+
+
+ {t("notification.suspendTime.30minutes")}
+
+
+ {t("notification.suspendTime.1hour")}
+
+
+ {t("notification.suspendTime.12hour")}
+
+
+ {t("notification.suspendTime.24hour")}
+
+
+ {t("notification.suspendTime.untilRestart")}
+
) : (
@@ -697,7 +725,7 @@ export function CameraNotificationSwitch({
size="sm"
onClick={handleCancelSuspension}
>
- Cancel Suspension
+ {t("notification.cancelSuspension")}
)}
diff --git a/web/src/views/settings/ObjectSettingsView.tsx b/web/src/views/settings/ObjectSettingsView.tsx
index ea1083ec1..90a61faa3 100644
--- a/web/src/views/settings/ObjectSettingsView.tsx
+++ b/web/src/views/settings/ObjectSettingsView.tsx
@@ -23,9 +23,11 @@ import { getIconForLabel } from "@/utils/iconUtil";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { LuExternalLink, LuInfo } from "react-icons/lu";
import { Link } from "react-router-dom";
+
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
import { Separator } from "@/components/ui/separator";
import { isDesktop } from "react-device-detect";
+import { Trans, useTranslation } from "react-i18next";
type ObjectSettingsViewProps = {
selectedCamera?: string;
@@ -38,6 +40,8 @@ const emptyObject = Object.freeze({});
export default function ObjectSettingsView({
selectedCamera,
}: ObjectSettingsViewProps) {
+ const { t } = useTranslation(["views/settings"]);
+
const { data: config } = useSWR
("config");
const containerRef = useRef(null);
@@ -45,80 +49,45 @@ export default function ObjectSettingsView({
const DEBUG_OPTIONS = [
{
param: "bbox",
- title: "Bounding boxes",
- description: "Show bounding boxes around tracked objects",
+ title: t("debug.boundingBoxes.title"),
+ description: t("debug.boundingBoxes.desc"),
info: (
<>
- Object Bounding Box Colors
+ {t("debug.boundingBoxes.colors.label")}
-
- At startup, different colors will be assigned to each object label
-
-
- A dark blue thin line indicates that object is not detected at
- this current point in time
-
-
- A gray thin line indicates that object is detected as being
- stationary
-
-
- A thick line indicates that object is the subject of autotracking
- (when enabled)
-
+ debug.boundingBoxes.colors.info
>
),
},
{
param: "timestamp",
- title: "Timestamp",
- description: "Overlay a timestamp on the image",
+ title: t("debug.timestamp.title"),
+ description: t("debug.timestamp.desc"),
},
{
param: "zones",
- title: "Zones",
- description: "Show an outline of any defined zones",
+ title: t("debug.zones.title"),
+ description: t("debug.zones.desc"),
},
{
param: "mask",
- title: "Motion masks",
- description: "Show motion mask polygons",
+ title: t("debug.mask.title"),
+ description: t("debug.mask.desc"),
},
{
param: "motion",
- title: "Motion boxes",
- description: "Show boxes around areas where motion is detected",
- info: (
- <>
-
- Motion Boxes
-
-
- Red boxes will be overlaid on areas of the frame where motion is
- currently being detected
-
- >
- ),
+ title: t("debug.motion.title"),
+ description: t("debug.motion.desc"),
+ info: debug.motion.tips ,
},
{
param: "regions",
- title: "Regions",
- description:
- "Show a box of the region of interest sent to the object detector",
- info: (
- <>
-
- Region Boxes
-
-
- Bright green boxes will be overlaid on areas of interest in the
- frame that are being sent to the object detector.
-
- >
- ),
+ title: t("debug.regions.title"),
+ description: t("debug.regions.desc"),
+ info: debug.regions.tips ,
},
];
@@ -167,8 +136,8 @@ export default function ObjectSettingsView({
}, [options, optionsLoaded]);
useEffect(() => {
- document.title = "Object Settings - Frigate";
- }, []);
+ document.title = t("documentTitle.object");
+ }, [t]);
if (!cameraConfig) {
return ;
@@ -179,25 +148,19 @@ export default function ObjectSettingsView({
- Debug
+ {t("debug.title")}
- Frigate uses your detectors{" "}
- {config
- ? "(" +
- Object.keys(config?.detectors)
- .map((detector) => capitalizeFirstLetter(detector))
- .join(",") +
- ")"
- : ""}{" "}
- to detect objects in your camera's video stream.
-
-
- Debugging view shows a real-time view of tracked objects and their
- statistics. The object list shows a time-delayed summary of detected
- objects.
+ {t("debug.detectorDesc", {
+ detectors: config
+ ? Object.keys(config?.detectors)
+ .map((detector) => capitalizeFirstLetter(detector))
+ .join(",")
+ : "",
+ })}
+
{t("debug.desc")}
{config?.cameras[cameraConfig.name]?.webui_url && (
@@ -217,8 +180,10 @@ export default function ObjectSettingsView({
- Debugging
- Object List
+ {t("debug.debugging")}
+
+ {t("debug.objectList")}
+
@@ -277,21 +242,20 @@ export default function ObjectSettingsView({
className="mb-0 cursor-pointer capitalize text-primary"
htmlFor="debugdraw"
>
- Object Shape Filter Drawing
+ {t("debug.objectShapeFilterDrawing.title")}
- Info
+
+ {t("button.info", { ns: "common" })}
+
- Enable this option to draw a rectangle on the
- camera image to show its area and ratio. These
- values can then be used to set object shape filter
- parameters in your config.
+ {t("debug.objectShapeFilterDrawing.tips")}
- Read the documentation{" "}
+ {t("debug.objectShapeFilterDrawing.document")}
@@ -307,8 +271,7 @@ export default function ObjectSettingsView({
- Draw a rectangle on the image to view area and ratio
- details
+ {t("debug.objectShapeFilterDrawing.desc")}
("config");
const colormap = useMemo(() => {
@@ -409,7 +373,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
- Score
+ {t("debug.objectShapeFilterDrawing.score")}
{obj.score
? (obj.score * 100).toFixed(1).toString()
@@ -420,7 +384,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
- Ratio
+ {t("debug.objectShapeFilterDrawing.ratio")}
{obj.ratio ? obj.ratio.toFixed(2).toString() : "-"}
@@ -428,7 +392,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
- Area
+ {t("debug.objectShapeFilterDrawing.area")}
{obj.area ? (
<>
@@ -457,7 +421,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
);
})
) : (
-
No objects
+
{t("debug.noObjects")}
)}
);
diff --git a/web/src/views/settings/UiSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx
index 03375670f..c5b273027 100644
--- a/web/src/views/settings/UiSettingsView.tsx
+++ b/web/src/views/settings/UiSettingsView.tsx
@@ -18,13 +18,14 @@ import {
SelectItem,
SelectTrigger,
} from "../../components/ui/select";
+import { useTranslation } from "react-i18next";
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
const WEEK_STARTS_ON = ["Sunday", "Monday"];
export default function UiSettingsView() {
const { data: config } = useSWR
("config");
-
+ const { t } = useTranslation("views/settings");
const clearStoredLayouts = useCallback(() => {
if (!config) {
return [];
@@ -33,21 +34,29 @@ export default function UiSettingsView() {
Object.entries(config.camera_groups).forEach(async (value) => {
await delData(`${value[0]}-draggable-layout`)
.then(() => {
- toast.success(`Cleared stored layout for ${value[0]}`, {
- position: "top-center",
- });
+ toast.success(
+ t("general.toast.success.clearStoredLayout", {
+ cameraName: value[0],
+ }),
+ {
+ position: "top-center",
+ },
+ );
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to clear stored layout: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("general.toast.error.clearStoredLayoutFailed", { errorMessage }),
+ {
+ position: "top-center",
+ },
+ );
});
});
- }, [config]);
+ }, [config, t]);
const clearStreamingSettings = useCallback(async () => {
if (!config) {
@@ -56,7 +65,7 @@ export default function UiSettingsView() {
await delData(`streaming-settings`)
.then(() => {
- toast.success(`Cleared streaming settings for all camera groups.`, {
+ toast.success(t("general.toast.success.clearStreamingSettings"), {
position: "top-center",
});
})
@@ -65,15 +74,20 @@ export default function UiSettingsView() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
- toast.error(`Failed to clear streaming settings: ${errorMessage}`, {
- position: "top-center",
- });
+ toast.error(
+ t("general.toast.error.clearStreamingSettingsFailed", {
+ errorMessage,
+ }),
+ {
+ position: "top-center",
+ },
+ );
});
- }, [config]);
+ }, [config, t]);
useEffect(() => {
- document.title = "General Settings - Frigate";
- }, []);
+ document.title = t("documentTitle.general");
+ }, [t]);
// settings
@@ -88,13 +102,13 @@ export default function UiSettingsView() {
- General Settings
+ {t("general.title")}
- Live Dashboard
+ {t("general.liveDashboard.title")}
@@ -106,19 +120,11 @@ export default function UiSettingsView() {
onCheckedChange={setAutoLive}
/>
- Automatic Live View
+ {t("general.liveDashboard.automaticLiveView.label")}
-
- Automatically switch to a camera's live view when activity is
- detected. Disabling this option causes static camera images on
- the your dashboards to only update once per minute.{" "}
-
- This is a global setting but can be overridden on each
- camera in camera groups only .
-
-
+
{t("general.liveDashboard.automaticLiveView.desc")}
@@ -129,15 +135,11 @@ export default function UiSettingsView() {
onCheckedChange={setAlertVideos}
/>
- Play Alert Videos
+ {t("general.liveDashboard.playAlertVideos.label")}
-
- By default, recent alerts on the Live dashboard play as small
- looping videos. Disable this option to only show a static
- image of recent alerts on this device/browser.
-
+
{t("general.liveDashboard.playAlertVideos.desc")}
@@ -145,52 +147,53 @@ export default function UiSettingsView() {
-
Stored Layouts
-
-
- The layout of cameras in a camera group can be
- dragged/resized. The positions are stored in your browser's
- local storage.
-
+
+ {t("general.storedLayouts.title")}
+
+
+
{t("general.storedLayouts.desc")}
- Clear All Layouts
+ {t("general.storedLayouts.clearAll")}
-
Camera Group Streaming Settings
+
+ {t("general.cameraGroupStreaming.title")}
+
-
- Streaming settings for each camera group are stored in your
- browser's local storage.
-
+
{t("general.cameraGroupStreaming.desc")}
- Clear All Streaming Settings
+ {t("general.cameraGroupStreaming.clearAll")}
- Recordings Viewer
+ {t("general.recordingsViewer.title")}
-
Default Playback Rate
+
+ {t("general.recordingsViewer.defaultPlaybackRate.label")}
+
-
Default playback rate for recordings playback.
+
+ {t("general.recordingsViewer.defaultPlaybackRate.desc")}
+
@@ -218,14 +221,16 @@ export default function UiSettingsView() {
- Calendar
+ {t("general.calendar.title")}
-
First Weekday
+
+ {t("general.calendar.firstWeekday.label")}
+
-
The day that the weeks of the review calendar begin on.
+
{t("general.calendar.firstWeekday.desc")}
@@ -234,7 +239,10 @@ export default function UiSettingsView() {
onValueChange={(value) => setWeekStartsOn(parseInt(value))}
>
- {WEEK_STARTS_ON[weekStartsOn ?? 0]}
+ {t(
+ "general.calendar.firstWeekday." +
+ WEEK_STARTS_ON[weekStartsOn ?? 0].toLowerCase(),
+ )}
@@ -244,7 +252,7 @@ export default function UiSettingsView() {
className="cursor-pointer"
value={index.toString()}
>
- {day}
+ {t("general.calendar.firstWeekday." + day.toLowerCase())}
))}
diff --git a/web/src/views/system/CameraMetrics.tsx b/web/src/views/system/CameraMetrics.tsx
index 764f22e96..497e6f435 100644
--- a/web/src/views/system/CameraMetrics.tsx
+++ b/web/src/views/system/CameraMetrics.tsx
@@ -12,6 +12,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import useSWR from "swr";
+import { useTranslation } from "react-i18next";
type CameraMetricsProps = {
lastUpdated: number;
@@ -22,7 +23,7 @@ export default function CameraMetrics({
setLastUpdated,
}: CameraMetricsProps) {
const { data: config } = useSWR("config");
-
+ const { t } = useTranslation(["views/system"]);
// camera info dialog
const [showCameraInfoDialog, setShowCameraInfoDialog] = useState(false);
@@ -223,11 +224,13 @@ export default function CameraMetrics({
return (
-
Overview
+
+ {t("cameras.overview")}
+
{statsHistory.length != 0 ? (
-
Frames / Detections
+
{t("cameras.framesAndDetections")}
-
Camera Probe Info
+
+ {t("cameras.info.tips.title")}
+
-
Frames / Detections
+
+ {t("cameras.framesAndDetections")}
+
(
["stats/history", { keys: "embeddings,service" }],
@@ -87,7 +89,7 @@ export default function FeatureMetrics({
<>
- Features
+ {t("features.title")}
- Detectors
+ {t("general.detector.title")}
{statsHistory.length != 0 ? (
-
Detector Inference Speed
+
{t("general.detector.inferenceSpeed")}
{detInferenceTimeSeries.map((series) => (
- Detector CPU Usage
+ {t("general.detector.cpuUsage")}
{detCpuSeries.map((series) => (
- Detector Memory Usage
+ {t("general.detector.memoryUsage")}
{detMemSeries.map((series) => (
setShowVainfo(true)}
>
- Hardware Info
+ {t("general.hardwareInfo.title")}
)}
@@ -557,7 +558,9 @@ export default function GeneralMetrics({
>
{statsHistory.length != 0 ? (
-
GPU Usage
+
+ {t("general.hardwareInfo.gpuUsage")}
+
{gpuSeries.map((series) => (
{gpuMemSeries && (
-
GPU Memory
+
+ {t("general.hardwareInfo.gpuMemroy")}
+
{gpuMemSeries.map((series) => (
{gpuEncSeries && gpuEncSeries?.length != 0 && (
-
GPU Encoder
+
+ {t("general.hardwareInfo.gpuEncoder")}
+
{gpuEncSeries.map((series) => (
{gpuDecSeries && gpuDecSeries?.length != 0 && (
-
GPU Decoder
+
+ {t("general.hardwareInfo.gpuDecoder")}
+
{gpuDecSeries.map((series) => (
- Other Processes
+ {t("general.otherProcesses.title")}
{statsHistory.length != 0 ? (
-
Process CPU Usage
+
+ {t("general.otherProcesses.processCpuUsage")}
+
{otherProcessCpuSeries.map((series) => (
- Process Memory Usage
+
+ {t("general.otherProcesses.processMemoryUsage")}
+
{otherProcessMemSeries.map((series) => (
("config", {
revalidateOnFocus: false,
});
-
+ const { t } = useTranslation(["views/system"]);
const timezone = useTimezone(config);
const totalStorage = useMemo(() => {
@@ -75,29 +76,31 @@ export default function StorageMetrics({
return (
-
Overview
+
+ {t("storage.overview")}
+
- Recordings
+ {t("storage.recordings.title")}
-
- This value represents the total storage used by the recordings
- in Frigate's database. Frigate does not track storage usage
- for all files on your disk.
-
+ {t("storage.recordings.tips")}
@@ -108,7 +111,9 @@ export default function StorageMetrics({
/>
{earliestDate && (
- Earliest recording available: {" "}
+
+ {t("storage.recordings.earliestRecording")}
+ {" "}
{formatUnixTimestampToDateTime(earliestDate, {
timezone: timezone,
strftime_fmt:
@@ -135,7 +140,7 @@ export default function StorageMetrics({
- Camera Storage
+ {t("storage.cameraStorage.title")}