feat: add more i18n keys

This commit is contained in:
ZhaiSoul 2025-03-13 17:12:51 +08:00
parent 9e6272d065
commit 94d0c66402
92 changed files with 1040 additions and 398 deletions

View File

@ -36,6 +36,8 @@
"second": "{{time}} seconds", "second": "{{time}} seconds",
"formattedTimestamp": "%b %-d, %I:%M:%S %p", "formattedTimestamp": "%b %-d, %I:%M:%S %p",
"formattedTimestamp.24hour": "%b %-d, %H:%M:%S", "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": "%b %-d, %I:%M %p",
"formattedTimestampExcludeSeconds.24hour": "%b %-d, %H:%M", "formattedTimestampExcludeSeconds.24hour": "%b %-d, %H:%M",
"formattedTimestampWithYear": "%b %-d %Y, %I:%M %p", "formattedTimestampWithYear": "%b %-d %Y, %I:%M %p",
@ -48,6 +50,9 @@
"kph": "kph" "kph": "kph"
} }
}, },
"label": {
"back": "Go back"
},
"button": { "button": {
"apply": "Apply", "apply": "Apply",
"reset": "Reset", "reset": "Reset",
@ -75,7 +80,11 @@
"download": "Download", "download": "Download",
"info": "Info", "info": "Info",
"suspended": "Suspended", "suspended": "Suspended",
"unsuspended": "Unsuspend" "unsuspended": "Unsuspend",
"play": "Play",
"unselect": "Unselect",
"export": "Export",
"deleteNow": "Delete Now"
}, },
"menu": { "menu": {
"system": "System", "system": "System",
@ -87,13 +96,15 @@
"languages": "Languages", "languages": "Languages",
"language": { "language": {
"en": "English", "en": "English",
"zhCN": "简体中文(Simplified Chinese)" "zhCN": "简体中文(Simplified Chinese)",
"withSystem.label": "Use the system settings for languag"
}, },
"appearance": "Appearance", "appearance": "Appearance",
"darkMode": { "darkMode": {
"label": "Dark Mode", "label": "Dark Mode",
"light": "Light", "light": "Light",
"dark": "Dark" "dark": "Dark",
"withSystem.label": "Use the system settings for light or dark mode"
}, },
"withSystem": "System", "withSystem": "System",
"theme": { "theme": {
@ -136,5 +147,13 @@
"admin": "Admin", "admin": "Admin",
"viewer": "Viewer", "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." "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"
} }
} }

View File

@ -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."
}
}
}

View File

@ -4,6 +4,7 @@
"add": "Add camera groups", "add": "Add camera groups",
"edit": "Edit camera groups", "edit": "Edit camera groups",
"delete": { "delete": {
"label": "Delete Camera Group",
"confirm": "Confirm Delete", "confirm": "Confirm Delete",
"confirm.desc": "Are you sure you want to delete the camera group <em>{{name}}</em>?" "confirm.desc": "Are you sure you want to delete the camera group <em>{{name}}</em>?"
}, },
@ -25,6 +26,7 @@
"success": "Camera group ({{name}}) has been saved.", "success": "Camera group ({{name}}) has been saved.",
"camera": { "camera": {
"setting": { "setting": {
"label": "Camera Streaming Settings",
"title": "{{cameraName}} Streaming Settings", "title": "{{cameraName}} Streaming Settings",
"desc": "Change the live streaming options for this camera group's dashboard. <em>These settings are device/browser-specific.</em>", "desc": "Change the live streaming options for this camera group's dashboard. <em>These settings are device/browser-specific.</em>",
"audioIsAvailable": "Audio is available for this stream", "audioIsAvailable": "Audio is available for this stream",
@ -57,5 +59,19 @@
} }
} }
} }
},
"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"
} }
} }

View File

@ -15,10 +15,12 @@
"desc": "Objects in locations you want to avoid are not false positives. Submitting them as false positives will confuse the model." "desc": "Objects in locations you want to avoid are not false positives. Submitting them as false positives will confuse the model."
}, },
"review": { "review": {
"true.label": "Confirm this label for Frigate Plus",
"true_one": "This is a {{label}}", "true_one": "This is a {{label}}",
"true_other": "This is an {{label}}", "true_other": "This is an {{label}}",
"false_one": "This is not a {{label}}", "false_one": "This is not a {{label}}",
"false_other": "This is not an {{label}}", "false_other": "This is not an {{label}}",
"false.label": "Do not confirm this label for Frigate Plus",
"state.submitted": "Submitted" "state.submitted": "Submitted"
} }
}, },
@ -31,13 +33,18 @@
"fromTimeline": "Select from Timeline", "fromTimeline": "Select from Timeline",
"lastHour_one": "Last Hour", "lastHour_one": "Last Hour",
"lastHour_other": "Last {{count}} Hours", "lastHour_other": "Last {{count}} Hours",
"custom": "Custom" "custom": "Custom",
"start": "Start Time",
"start.label": "Select Start Time",
"end": "End Time",
"end.label": "Select End Time"
}, },
"name": { "name": {
"placeholder": "Name the Export" "placeholder": "Name the Export"
}, },
"select": "Select", "select": "Select",
"export": "Export", "export": "Export",
"selectOrExport": "Select or Export",
"toast": { "toast": {
"success": "Successfully started export. View the file in the /exports folder.", "success": "Successfully started export. View the file in the /exports folder.",
"error": { "error": {
@ -70,13 +77,15 @@
"desc": "Provide a name for this saved search.", "desc": "Provide a name for this saved search.",
"placeholder": "Enter a name for your search", "placeholder": "Enter a name for your search",
"overwrite": "{{searchName}} already exists. Saving will overwrite the existing value.", "overwrite": "{{searchName}} already exists. Saving will overwrite the existing value.",
"success": "Search ({{searchName}}) has been saved." "success": "Search ({{searchName}}) has been saved.",
"button.save.label": "Save this search"
} }
}, },
"recording": { "recording": {
"confirmDelete": { "confirmDelete": {
"title": "Confirm Delete", "title": "Confirm Delete",
"desc": "Are you sure you want to delete all recorded video associated with this review item?<br /><br />Hold the <em>Shift</em> key to bypass this dialog in the future." "desc": "Are you sure you want to delete all recorded video associated with this review item?<br /><br />Hold the <em>Shift</em> key to bypass this dialog in the future.",
"desc.selected": "Are you sure you want to delete all recorded video associated with this review item?<br /><br />Hold the <em>Shift</em> key to bypass this dialog in the future."
}, },
"button": { "button": {
"export": "Export", "export": "Export",

View File

@ -1,6 +1,7 @@
{ {
"filter": "Filter", "filter": "Filter",
"labels": { "labels": {
"label": "Labels",
"all": "All Labels", "all": "All Labels",
"all.short": "Labels", "all.short": "Labels",
"count": "{{count}} Labels" "count": "{{count}} Labels"
@ -14,6 +15,7 @@
"all.short": "Dates" "all.short": "Dates"
}, },
"more": "More Filters", "more": "More Filters",
"reset.label": "Reset filters to default values",
"timeRange": "Time Range", "timeRange": "Time Range",
"zones.label": "Zones", "zones.label": "Zones",
"subLabels": { "subLabels": {
@ -42,12 +44,16 @@
"relevance": "Relevance" "relevance": "Relevance"
}, },
"cameras": { "cameras": {
"label": "Cameras Filter",
"all": "All Cameras", "all": "All Cameras",
"all.short": "Cameras" "all.short": "Cameras"
}, },
"review": { "review": {
"showReviewed": "Show Reviewed" "showReviewed": "Show Reviewed"
}, },
"motion": {
"showMotionOnly": "Show Motion Only"
},
"explore": { "explore": {
"settings": { "settings": {
"title": "Settings", "title": "Settings",
@ -65,6 +71,30 @@
"description": "Description" "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 <em>NOT</em> be deleted.<br /><br />Are you sure you want to proceed?<br /><br />Hold the <em>Shift</em> 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"
} }
} }

View File

@ -0,0 +1,10 @@
{
"button": {
"downloadVideo": {
"label": "Download Video",
"toast": {
"success": "Your review item video has started downloading."
}
}
}
}

View File

@ -1,5 +1,9 @@
{ {
"noRecordingsFoundForThisTime": "No recordings found for this time", "noRecordingsFoundForThisTime": "No recordings found for this time",
"noPreviewFound": "No Preview Found", "noPreviewFound": "No Preview Found",
"noPreviewFoundFor": "No Preview Found for {{cameraName}}" "noPreviewFoundFor": "No Preview Found for {{cameraName}}",
"submitFrigatePlus": {
"title": "Submit this frame to Frigate+?",
"submit": "Submit"
}
} }

View File

@ -12,8 +12,10 @@
"motion": "No motion data found" "motion": "No motion data found"
}, },
"timeline": "Timeline", "timeline": "Timeline",
"timeline.aria": "Select timeline",
"events": { "events": {
"label": "Events", "label": "Events",
"aria": "Select events",
"noFoundForTimePeriod": "No events found for this time period." "noFoundForTimePeriod": "No events found for this time period."
}, },
"documentTitle": "Review - Frigate", "documentTitle": "Review - Frigate",
@ -22,5 +24,12 @@
}, },
"calendarFilter": { "calendarFilter": {
"last24Hours": "Last 24 Hours" "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"
} }

View File

@ -34,6 +34,43 @@
"video": "video", "video": "video",
"object_lifecycle": "object lifecycle" "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 <code>annotation_offset</code> field can be used to adjust this.",
"documentation": "Read the documentation ",
"millisecondsToOffset": "Milliseconds to offset detect annotations by. <em>Default: 0</em>",
"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": { "details": {
"item": { "item": {
"title": "Review Item Details", "title": "Review Item Details",
@ -68,6 +105,8 @@
"aiTips": "Frigate will not request a description from your Generative AI provider until the tracked object's lifecycle has ended." "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": "Regenerate",
"button.regenerate.label": "Regenerate tracked object description",
"expandRegenerationMenu": "Expand regeneration menu",
"regenerateFromSnapshot": "Regenerate from Snapshot", "regenerateFromSnapshot": "Regenerate from Snapshot",
"regenerateFromThumbnails": "Regenerate from Thumbnails", "regenerateFromThumbnails": "Regenerate from Thumbnails",
"tips": { "tips": {
@ -99,6 +138,9 @@
"viewInHistory": { "viewInHistory": {
"label": "View in History", "label": "View in History",
"aria": "View in History" "aria": "View in History"
},
"deleteTrackedObject": {
"label": "Delete this tracked object"
} }
}, },
"dialog": { "dialog": {

View File

@ -3,5 +3,11 @@
"search": "Search", "search": "Search",
"noExports": "No exports found", "noExports": "No exports found",
"deleteExport": "Delete Export", "deleteExport": "Delete Export",
"deleteExport.desc": "Are you sure you want to delete {{exportName}}?" "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"
}
} }

View File

@ -12,6 +12,11 @@
}, },
"ptz": { "ptz": {
"move": { "move": {
"clickMove": {
"label": "Click in the frame to center the camera",
"enable": "Enable click to move",
"disable": "Disable click to move"
},
"left": { "left": {
"label": "Move PTZ camera to the left" "label": "Move PTZ camera to the left"
}, },
@ -37,7 +42,8 @@
"center": { "center": {
"label": "Click in the frame to center the PTZ camera" "label": "Click in the frame to center the PTZ camera"
} }
} },
"presets": "PTZ camera presets"
}, },
"camera": { "camera": {
"enable": "Enable Camera", "enable": "Enable Camera",
@ -118,8 +124,7 @@
"playInBackground": { "playInBackground": {
"label": "Play in background", "label": "Play in background",
"tips": "Enable this option to continue streaming when the player is hidden." "tips": "Enable this option to continue streaming when the player is hidden."
}, }
"": ""
}, },
"cameraSettings": { "cameraSettings": {
"title": "{{camera}} Settings", "title": "{{camera}} Settings",
@ -129,5 +134,16 @@
"snapshots": "Snapshots", "snapshots": "Snapshots",
"audioDetection": "Audio Detection", "audioDetection": "Audio Detection",
"autotracking": "Autotracking" "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 <code>mode: {{effectiveRetainMode}}</code>, so this on-demand recording will only keep segments with {{effectiveRetainModeName}}."
} }
} }

View File

@ -2,6 +2,7 @@
"export": "Export", "export": "Export",
"calendar": "Calendar", "calendar": "Calendar",
"filter": "Filter", "filter": "Filter",
"filters": "Filters",
"toast": { "toast": {
"error": { "error": {
"noValidTimeSelected": "No valid time range selected", "noValidTimeSelected": "No valid time range selected",

View File

@ -122,6 +122,17 @@
"inertia.error.mustBeAboveZero": "Inertia must be above 0.", "inertia.error.mustBeAboveZero": "Inertia must be above 0.",
"loiteringTime.error.mustBeGreaterOrEqualZero": "Loitering time must be greater than or equal to 0.", "loiteringTime.error.mustBeGreaterOrEqualZero": "Loitering time must be greater than or equal to 0.",
"polygonDrawing": { "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}} <em>{{name}}</em>?",
"success": "{{name}} has been deleted."
},
"error": { "error": {
"mustBeFinished": "Polygon drawing must be finished before saving." "mustBeFinished": "Polygon drawing must be finished before saving."
} }

View File

@ -1,7 +1,23 @@
{ {
"title": "System", "title": "System",
"metrics": "System metrics", "metrics": "System metrics",
"logs": "System logs", "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"
},
"general": { "general": {
"title": "General", "title": "General",
"detector": { "detector": {
@ -15,7 +31,24 @@
"gpuUsage": "GPU Usage", "gpuUsage": "GPU Usage",
"gpuMemory": "GPU Memory", "gpuMemory": "GPU Memory",
"gpuEncoder": "GPU Encoder", "gpuEncoder": "GPU Encoder",
"gpuDecoder": "GPU Decoder" "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": "Close GPU info"
}
}, },
"otherProcesses": { "otherProcesses": {
"title": "Other Processes", "title": "Other Processes",
@ -28,12 +61,14 @@
"overview": "Overview", "overview": "Overview",
"recordings": { "recordings": {
"title": "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." "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": { "cameraStorage": {
"title": "Camera Storage", "title": "Camera Storage",
"camera": "Camera", "camera": "Camera",
"unused": "Unused", "unused": "Unused",
"unusedStorageInformation": "Unused Storage Information",
"storageUsed": "Storage Used", "storageUsed": "Storage Used",
"percentageOfTotalUsed": "Percentage of Total Used", "percentageOfTotalUsed": "Percentage of Total Used",
"bandwidth": "Bandwidth", "bandwidth": "Bandwidth",

View File

@ -48,6 +48,14 @@
"kph": "英里/小时" "kph": "英里/小时"
} }
}, },
"pagination": {
"label": "分页",
"previous": "上一页",
"previous.label": "转到上一页",
"next": "下一页",
"next.label": "转到下一页",
"more": "更多页面"
},
"button": { "button": {
"apply": "应用", "apply": "应用",
"reset": "重置", "reset": "重置",
@ -75,7 +83,11 @@
"download": "下载", "download": "下载",
"info": "信息", "info": "信息",
"suspended": "已暂停", "suspended": "已暂停",
"unsuspended": "取消暂停" "unsuspended": "取消暂停",
"play": "播放",
"unselect": "取消选择",
"export": "导出",
"deleteNow": "立即删除"
}, },
"menu": { "menu": {
"system": "系统", "system": "系统",
@ -87,13 +99,15 @@
"languages": "languages / 语言", "languages": "languages / 语言",
"language": { "language": {
"en": "English", "en": "English",
"zhCN": "简体中文" "zhCN": "简体中文",
"withSystem.label": "使用系统语言设置"
}, },
"appearance": "外观", "appearance": "外观",
"darkMode": { "darkMode": {
"label": "深色模式", "label": "深色模式",
"light": "浅色", "light": "浅色",
"dark": "深色" "dark": "深色",
"withSystem.label": "使用系统深色模式设置"
}, },
"withSystem": "跟随系统", "withSystem": "跟随系统",
"theme": { "theme": {

View File

@ -0,0 +1,15 @@
{
"form": {
"user": "用户名",
"password": "密码",
"login": "登录",
"errors": {
"usernameRequired": "用户名不能为空",
"passwordRequired": "密码不能为空",
"rateLimit": "超出请求限制,请稍后再试。",
"loginFailed": "登录失败",
"unknownError": "未知错误,请检查日志。",
"webUnkownError": "未知错误,请检查控制台日志。"
}
}
}

View File

@ -4,6 +4,7 @@
"add": "添加摄像头组", "add": "添加摄像头组",
"edit": "编辑摄像头组", "edit": "编辑摄像头组",
"delete": { "delete": {
"label": "删除摄像头组",
"confirm": "确认删除", "confirm": "确认删除",
"confirm.desc": "你确定要删除摄像头组 <em>{{name}}</em> 吗?" "confirm.desc": "你确定要删除摄像头组 <em>{{name}}</em> 吗?"
}, },
@ -25,6 +26,7 @@
"success": "摄像头组({{name}})保存成功。", "success": "摄像头组({{name}})保存成功。",
"camera": { "camera": {
"setting": { "setting": {
"label": "摄像头视频流设置",
"title": "{{cameraName}} 视频流设置", "title": "{{cameraName}} 视频流设置",
"desc": "更改此摄像头组仪表板的实时视频流选项。<em>这些设置特定于设备/浏览器。</em>", "desc": "更改此摄像头组仪表板的实时视频流选项。<em>这些设置特定于设备/浏览器。</em>",
"audioIsAvailable": "此视频流支持音频", "audioIsAvailable": "此视频流支持音频",
@ -57,5 +59,19 @@
} }
} }
} }
},
"debug": {
"options": {
"label": "设置",
"title": "选项",
"showOptions": "显示选项",
"hideOptions": "隐藏选项"
},
"boundingBox": "边界框",
"timestamp": "时间戳",
"zones": "区域",
"mask": "遮罩",
"motion": "运动",
"regions": "区域"
} }
} }

View File

@ -15,8 +15,10 @@
"desc": "您希望避开的地点中的物体不应被视为误报。若将其作为误报提交可能会导致AI模型容易混淆相关物体的识别。" "desc": "您希望避开的地点中的物体不应被视为误报。若将其作为误报提交可能会导致AI模型容易混淆相关物体的识别。"
}, },
"review": { "review": {
"true.label": "为 Frigate Plus 确认此标签",
"true_one": "这是 {{label}}", "true_one": "这是 {{label}}",
"true_other": "这是 {{label}}", "true_other": "这是 {{label}}",
"false.label": "不为 Frigate Plus 确认此标签",
"false_one": "这不是 {{label}}", "false_one": "这不是 {{label}}",
"false_other": "这不是 {{label}}", "false_other": "这不是 {{label}}",
"state.submitted": "已提交" "state.submitted": "已提交"
@ -31,13 +33,18 @@
"fromTimeline": "从时间线选择", "fromTimeline": "从时间线选择",
"lastHour_one": "最后1小时", "lastHour_one": "最后1小时",
"lastHour_other": "最后 {{count}} 小时", "lastHour_other": "最后 {{count}} 小时",
"custom": "自定义" "custom": "自定义",
"start": "开始时间",
"start.label": "选择开始时间",
"end": "结束时间",
"end.label": "选择结束时间"
}, },
"name": { "name": {
"placeholder": "导出项目的名字" "placeholder": "导出项目的名字"
}, },
"select": "选择", "select": "选择",
"export": "导出", "export": "导出",
"selectOrExport": "选择或导出",
"toast": { "toast": {
"success": "导出成功。进入 /exports 目录查看文件。", "success": "导出成功。进入 /exports 目录查看文件。",
"error": { "error": {
@ -70,13 +77,15 @@
"desc": "请为此已保存的搜索提供一个名称。", "desc": "请为此已保存的搜索提供一个名称。",
"placeholder": "请输入搜索名称", "placeholder": "请输入搜索名称",
"overwrite": "{{searchName}} 已存在。保存将覆盖现有值。", "overwrite": "{{searchName}} 已存在。保存将覆盖现有值。",
"success": "搜索 ({{searchName}}) 已保存。" "success": "搜索 ({{searchName}}) 已保存。",
"button.save.label": "保存此搜索"
} }
}, },
"recording": { "recording": {
"confirmDelete": { "confirmDelete": {
"title": "确认删除", "title": "确认删除",
"desc": "您确定要删除与此审核项相关的所有录制视频吗?<br /><br />提示:按住 <em>Shift</em> 键点击删除可跳过此对话框。" "desc": "您确定要删除与此审核项相关的所有录制视频吗?<br /><br />提示:按住 <em>Shift</em> 键点击删除可跳过此对话框。",
"desc.selected": "您确定要删除与此审核项相关的所有录制视频吗?<br /><br />提示:按住 <em>Shift</em> 键点击删除可跳过此对话框。"
}, },
"button": { "button": {
"export": "导出", "export": "导出",

View File

@ -1,6 +1,7 @@
{ {
"filter": "过滤器", "filter": "过滤器",
"labels": { "labels": {
"label": "标签",
"all": "所有标签", "all": "所有标签",
"all.short": "标签", "all.short": "标签",
"count": "{{count}} 个标签" "count": "{{count}} 个标签"
@ -14,6 +15,7 @@
"all.short": "日期" "all.short": "日期"
}, },
"more": "更多筛选项", "more": "更多筛选项",
"reset.label": "重置筛选器为默认值",
"timeRange": "时间范围", "timeRange": "时间范围",
"zones.label": "区域", "zones.label": "区域",
"subLabels": { "subLabels": {
@ -42,12 +44,16 @@
"relevance": "关联性" "relevance": "关联性"
}, },
"cameras": { "cameras": {
"label": "摄像头筛选",
"all": "所有摄像头", "all": "所有摄像头",
"all.short": "摄像头" "all.short": "摄像头"
}, },
"review": { "review": {
"showReviewed": "显示已查看的项目" "showReviewed": "显示已查看的项目"
}, },
"motion": {
"showMotionOnly": "仅显示运动"
},
"explore": { "explore": {
"settings": { "settings": {
"title": "设置", "title": "设置",
@ -64,8 +70,31 @@
"thumbnailImage": "缩略图", "thumbnailImage": "缩略图",
"description": "描述" "description": "描述"
} }
},
"date": {
"selectDateBy": {
"label": "选择日期进行筛选"
}
} }
} }
},
"logSettings": {
"label": "日志级别筛选",
"filterBySeverity": "按严重程度筛选日志",
"loading": "加载中",
"loading.desc": "当日志面板滚动到底部时,新的日志会自动流式加载。",
"disableLogStreaming": "禁用日志流式加载",
"allLogs": "所有日志"
},
"trackedObjectDelete": {
"title": "确认删除",
"desc": "删除这 {{objectLength}} 个跟踪对象将移除快照、任何已保存的嵌入和任何相关的对象生命周期条目。历史视图中这些跟踪对象的录制片段将<em>不会</em>被删除。<br /><br />您确定要继续吗?<br /><br />按住 <em>Shift</em> 键可在将来跳过此对话框。",
"toast": {
"success": "跟踪对象删除成功。",
"error": "删除跟踪对象失败:{{errorMessage}}"
}
},
"zoneMask": {
"filterBy": "按区域遮罩筛选"
} }
} }

View File

@ -1,3 +1,10 @@
{ {
"button": {
"downloadVideo": {
"label": "下载视频",
"toast": {
"success": "下载成功"
}
}
}
} }

View File

@ -1,5 +1,9 @@
{ {
"noRecordingsFoundForThisTime": "找不到此次录制", "noRecordingsFoundForThisTime": "找不到此次录制",
"noPreviewFound": "没有找到预览", "noPreviewFound": "没有找到预览",
"noPreviewFoundFor": "没有在 {{cameraName}} 下找到预览" "noPreviewFoundFor": "没有在 {{cameraName}} 下找到预览",
"submitFrigatePlus": {
"title": "提交此帧到 Frigate+",
"submit": "提交"
}
} }

View File

@ -12,8 +12,10 @@
"motion": "还没有运动类数据" "motion": "还没有运动类数据"
}, },
"timeline": "时间线", "timeline": "时间线",
"timeline.aria": "选择时间线",
"events": { "events": {
"label": "事件", "label": "事件",
"aria": "选择事件",
"noFoundForTimePeriod": "未找到该时间段的事件。" "noFoundForTimePeriod": "未找到该时间段的事件。"
}, },
"documentTitle": "预览 - Frigate", "documentTitle": "预览 - Frigate",
@ -22,5 +24,12 @@
}, },
"calendarFilter": { "calendarFilter": {
"last24Hours": "过去24小时" "last24Hours": "过去24小时"
} },
"markAsReviewed": "标记为已审核",
"markTheseItemsAsReviewed": "将这些项目标记为已审核",
"newReviewItems": {
"label": "查看新的审核项目",
"button": "新的待审核项目"
},
"camera": "摄像头"
} }

View File

@ -34,6 +34,43 @@
"video": "视频", "video": "视频",
"object_lifecycle": "对象生命周期" "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": "这些数据来自摄像头的检测源,但是叠加在录制源的图像上。这两个流不太可能完全同步。因此,边界框和录像不会完全对齐。但是,可以使用 <code>annotation_offset</code> 字段来调整这个问题。",
"documentation": "阅读文档(英文) ",
"millisecondsToOffset": "检测标注的偏移毫秒数。<em>默认值0</em>",
"tips": "提示:假设有一个人从左向右走的事件片段。如果事件时间线上的边界框始终在人的左侧,则应该减小该值。同样,如果一个人从左向右走,而边界框始终在人的前面,则应该增加该值。"
}
},
"carousel": {
"previous": "上一张",
"next": "下一张"
}
},
"details": { "details": {
"item": { "item": {
"title": "回放项目详情", "title": "回放项目详情",
@ -68,6 +105,8 @@
"aiTips": "在跟踪对象的生命周期结束之前Frigate 不会向您的生成式 AI 提供商请求描述。" "aiTips": "在跟踪对象的生命周期结束之前Frigate 不会向您的生成式 AI 提供商请求描述。"
}, },
"button.regenerate": "重新生成", "button.regenerate": "重新生成",
"button.regenerate.label": "重新生成跟踪对象描述",
"expandRegenerationMenu": "展开重新生成菜单",
"regenerateFromSnapshot": "从快照重新生成", "regenerateFromSnapshot": "从快照重新生成",
"regenerateFromThumbnails": "从缩略图重新生成", "regenerateFromThumbnails": "从缩略图重新生成",
"tips": { "tips": {
@ -99,6 +138,9 @@
"viewInHistory": { "viewInHistory": {
"label": "在历史记录中查看", "label": "在历史记录中查看",
"aria": "在历史记录中查看" "aria": "在历史记录中查看"
},
"deleteTrackedObject": {
"label": "删除此跟踪对象"
} }
}, },
"dialog": { "dialog": {

View File

@ -3,5 +3,10 @@
"search": "搜索", "search": "搜索",
"noExports": "没有找到导出的项目", "noExports": "没有找到导出的项目",
"deleteExport": "删除导出的项目", "deleteExport": "删除导出的项目",
"deleteExport.desc": "你确定要删除 {{exportName}} 吗?" "deleteExport.desc": "你确定要删除 {{exportName}} 吗?",
"editExport": {
"title": "重命名导出",
"desc": "为此导出项目输入新名称。",
"saveExport": "保存导出"
}
} }

View File

@ -12,6 +12,11 @@
}, },
"ptz": { "ptz": {
"move": { "move": {
"clickMove": {
"label": "点击画面以使摄像头居中",
"enable": "启用点击移动",
"disable": "禁用点击移动"
},
"left": { "left": {
"label": "PTZ摄像头向左移动" "label": "PTZ摄像头向左移动"
}, },
@ -37,7 +42,8 @@
"center": { "center": {
"label": "点击将PTZ摄像头画面居中" "label": "点击将PTZ摄像头画面居中"
} }
} },
"presets": "PTZ摄像头预设"
}, },
"camera": { "camera": {
"enable": "开启摄像头", "enable": "开启摄像头",
@ -128,5 +134,16 @@
"snapshots": "快照", "snapshots": "快照",
"audioDetection": "音频检测", "audioDetection": "音频检测",
"autotracking": "自动跟踪" "autotracking": "自动跟踪"
},
"history": {
"label": "显示历史录像"
},
"effectiveRetainMode": {
"modes": {
"all": "全部",
"motion": "运动",
"active_objects": "活动对象"
},
"notAllTips": "您的 {{source}} 录制保留配置设置为 <code>mode: {{effectiveRetainMode}}</code>,因此此按需录制将仅保留包含 {{effectiveRetainModeName}} 的片段。"
} }
} }

View File

@ -2,6 +2,7 @@
"export": "导出", "export": "导出",
"calendar": "日历", "calendar": "日历",
"filter": "筛选", "filter": "筛选",
"filters": "筛选条件",
"toast": { "toast": {
"error": { "error": {
"noValidTimeSelected": "未选择有效的时间范围", "noValidTimeSelected": "未选择有效的时间范围",

View File

@ -1,7 +1,23 @@
{ {
"title": "系统", "title": "系统",
"metrics": "系统指标", "metrics": "系统指标",
"logs": "系统日志", "logs": {
"download": {
"label": "下载日志"
},
"copy": {
"label": "复制到剪贴板",
"success": "已复制日志到剪贴板",
"error": "无法复制日志到剪贴板"
},
"type": {
"label": "类型",
"timestamp": "时间戳",
"tag": "标签",
"message": "消息"
},
"tips": "日志正在从服务器流式传输"
},
"general": { "general": {
"title": "常规", "title": "常规",
"detector": { "detector": {
@ -15,7 +31,24 @@
"gpuUsage": "GPU使用率", "gpuUsage": "GPU使用率",
"gpuMemory": "GPU显存", "gpuMemory": "GPU显存",
"gpuEncoder": "GPU编码", "gpuEncoder": "GPU编码",
"gpuDecoder": "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信息"
}
}, },
"otherProcesses": { "otherProcesses": {
"title": "其他进程", "title": "其他进程",
@ -28,12 +61,14 @@
"overview": "概览", "overview": "概览",
"recordings": { "recordings": {
"title": "录制内容", "title": "录制内容",
"tips": "该值表示 Frigate 数据库中录制内容所使用的总存储空间。Frigate 不会追踪磁盘上所有文件的存储使用情况。" "tips": "该值表示 Frigate 数据库中录制内容所使用的总存储空间。Frigate 不会追踪磁盘上所有文件的存储使用情况。",
"earliestRecording": "最早的可用录制:"
}, },
"cameraStorage": { "cameraStorage": {
"title": "摄像头存储", "title": "摄像头存储",
"camera": "摄像头", "camera": "摄像头",
"unused": "未使用", "unused": "未使用",
"unusedStorageInformation": "未使用存储信息",
"storageUsed": "存储使用", "storageUsed": "存储使用",
"percentageOfTotalUsed": "总使用率", "percentageOfTotalUsed": "总使用率",
"bandwidth": "带宽", "bandwidth": "带宽",

View File

@ -21,16 +21,18 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { AuthContext } from "@/context/auth-context"; import { AuthContext } from "@/context/auth-context";
import { useTranslation } from "react-i18next";
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {} interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
export function UserAuthForm({ className, ...props }: UserAuthFormProps) { export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const { t } = useTranslation(["components/auth"]);
const [isLoading, setIsLoading] = React.useState<boolean>(false); const [isLoading, setIsLoading] = React.useState<boolean>(false);
const { login } = React.useContext(AuthContext); const { login } = React.useContext(AuthContext);
const formSchema = z.object({ const formSchema = z.object({
user: z.string().min(1, "Username is required"), user: z.string().min(1, t("form.errors.usernameRequired")),
password: z.string().min(1, "Password is required"), password: z.string().min(1, t("form.errors.passwordRequired")),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@ -62,20 +64,20 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const err = error as AxiosError; const err = error as AxiosError;
if (err.response?.status === 429) { if (err.response?.status === 429) {
toast.error("Exceeded rate limit. Try again later.", { toast.error(t("form.errors.rateLimit"), {
position: "top-center", position: "top-center",
}); });
} else if (err.response?.status === 401) { } else if (err.response?.status === 401) {
toast.error("Login failed", { toast.error(t("form.errors.loginFailed"), {
position: "top-center", position: "top-center",
}); });
} else { } else {
toast.error("Unknown error. Check logs.", { toast.error(t("form.errors.unknownError"), {
position: "top-center", position: "top-center",
}); });
} }
} else { } else {
toast.error("Unknown error. Check console logs.", { toast.error(t("form.errors.webUnkownError"), {
position: "top-center", position: "top-center",
}); });
} }
@ -92,7 +94,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
name="user" name="user"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>User</FormLabel> <FormLabel>{t("form.user")}</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
@ -107,7 +109,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel>{t("form.password")}</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
@ -123,10 +125,10 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
variant="select" variant="select"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Login" aria-label={t("form.login")}
> >
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />} {isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />}
Login {t("form.login")}
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -3,6 +3,7 @@ import { toast } from "sonner";
import { FaDownload } from "react-icons/fa"; import { FaDownload } from "react-icons/fa";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type DownloadVideoButtonProps = { type DownloadVideoButtonProps = {
source: string; source: string;
@ -17,6 +18,7 @@ export function DownloadVideoButton({
startTime, startTime,
className, className,
}: DownloadVideoButtonProps) { }: DownloadVideoButtonProps) {
const { t } = useTranslation(["components/input"]);
const formattedDate = formatUnixTimestampToDateTime(startTime, { const formattedDate = formatUnixTimestampToDateTime(startTime, {
strftime_fmt: "%D-%T", strftime_fmt: "%D-%T",
time_style: "medium", time_style: "medium",
@ -25,7 +27,7 @@ export function DownloadVideoButton({
const filename = `${camera}_${formattedDate}.mp4`; const filename = `${camera}_${formattedDate}.mp4`;
const handleDownloadStart = () => { const handleDownloadStart = () => {
toast.success("Your review item video has started downloading.", { toast.success(t("button.downloadVideo.toast.success"), {
position: "top-center", position: "top-center",
}); });
}; };
@ -36,7 +38,7 @@ export function DownloadVideoButton({
asChild asChild
className="flex items-center gap-2" className="flex items-center gap-2"
size="sm" size="sm"
aria-label="Download Video" aria-label={t("button.downloadVideo.label")}
> >
<a href={source} download={filename} onClick={handleDownloadStart}> <a href={source} download={filename} onClick={handleDownloadStart}>
<FaDownload <FaDownload

View File

@ -7,6 +7,7 @@ import { useCallback, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage"; import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage";
import { useTranslation } from "react-i18next";
type Options = { [key: string]: boolean }; type Options = { [key: string]: boolean };
@ -21,6 +22,7 @@ export default function DebugCameraImage({
className, className,
cameraConfig, cameraConfig,
}: DebugCameraImageProps) { }: DebugCameraImageProps) {
const { t } = useTranslation(["components/camera"]);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [options, setOptions] = usePersistence<Options>( const [options, setOptions] = usePersistence<Options>(
`${cameraConfig?.name}-feed`, `${cameraConfig?.name}-feed`,
@ -59,17 +61,21 @@ export default function DebugCameraImage({
onClick={handleToggleSettings} onClick={handleToggleSettings}
variant="link" variant="link"
size="sm" size="sm"
aria-label="Settings" aria-label={t("debug.options.label")}
> >
<span className="h-5 w-5"> <span className="h-5 w-5">
<LuSettings /> <LuSettings />
</span>{" "} </span>{" "}
<span>{showSettings ? "Hide" : "Show"} Options</span> <span>
{showSettings
? t("debug.options.hideOptions")
: t("debug.options.showOptions")}
</span>
</Button> </Button>
{showSettings ? ( {showSettings ? (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Options</CardTitle> <CardTitle>{t("debug.options.title")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<DebugSettings <DebugSettings
@ -89,6 +95,7 @@ type DebugSettingsProps = {
}; };
function DebugSettings({ handleSetOption, options }: DebugSettingsProps) { function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
const { t } = useTranslation(["components/camera"]);
return ( return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -99,7 +106,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("bbox", isChecked); handleSetOption("bbox", isChecked);
}} }}
/> />
<Label htmlFor="bbox">Bounding Box</Label> <Label htmlFor="bbox">{t("debug.boundingBox")}</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
@ -109,7 +116,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("timestamp", isChecked); handleSetOption("timestamp", isChecked);
}} }}
/> />
<Label htmlFor="timestamp">Timestamp</Label> <Label htmlFor="timestamp">{t("debug.timestamp")}</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
@ -119,7 +126,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("zones", isChecked); handleSetOption("zones", isChecked);
}} }}
/> />
<Label htmlFor="zones">Zones</Label> <Label htmlFor="zones">{t("debug.zones")}</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
@ -129,7 +136,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("mask", isChecked); handleSetOption("mask", isChecked);
}} }}
/> />
<Label htmlFor="mask">Mask</Label> <Label htmlFor="mask">{t("debug.mask")}</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
@ -139,7 +146,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("motion", isChecked); handleSetOption("motion", isChecked);
}} }}
/> />
<Label htmlFor="motion">Motion</Label> <Label htmlFor="motion">{t("debug.motion")}</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
@ -149,7 +156,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("regions", isChecked); handleSetOption("regions", isChecked);
}} }}
/> />
<Label htmlFor="regions">Regions</Label> <Label htmlFor="regions">{t("debug.regions")}</Label>
</div> </div>
</div> </div>
); );

View File

@ -18,6 +18,7 @@ import { Skeleton } from "../ui/skeleton";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type AnimatedEventCardProps = { type AnimatedEventCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -29,6 +30,7 @@ export function AnimatedEventCard({
selectedGroup, selectedGroup,
updateEvents, updateEvents,
}: AnimatedEventCardProps) { }: AnimatedEventCardProps) {
const { t } = useTranslation(["views/events"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const apiHost = useApiHost(); const apiHost = useApiHost();
@ -121,7 +123,7 @@ export function AnimatedEventCard({
<Button <Button
className="absolute right-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500" className="absolute right-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
size="xs" size="xs"
aria-label="Mark as Reviewed" aria-label={t("markAsReviewed")}
onClick={async () => { onClick={async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] }); await axios.post(`reviews/viewed`, { ids: [event.id] });
updateEvents(); updateEvents();
@ -130,7 +132,7 @@ export function AnimatedEventCard({
<FaCircleCheck className="size-3 text-white" /> <FaCircleCheck className="size-3 text-white" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Mark as Reviewed</TooltipContent> <TooltipContent>{t("markAsReviewed")}</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{previews != undefined && ( {previews != undefined && (

View File

@ -20,6 +20,7 @@ import { MdEditSquare } from "react-icons/md";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil"; import { shareOrCopy } from "@/utils/browserUtil";
import { useTranslation } from "react-i18next";
type ExportProps = { type ExportProps = {
className: string; className: string;
@ -36,6 +37,7 @@ export default function ExportCard({
onRename, onRename,
onDelete, onDelete,
}: ExportProps) { }: ExportProps) {
const { t } = useTranslation(["views/exports"]);
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const [loading, setLoading] = useState( const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0, exportedRecording.thumb_path.length > 0,
@ -89,10 +91,8 @@ export default function ExportCard({
} }
}} }}
> >
<DialogTitle>Rename Export</DialogTitle> <DialogTitle>{t("editExport.title")}</DialogTitle>
<DialogDescription> <DialogDescription>{t("editExport.desc")}</DialogDescription>
Enter a new name for this export.
</DialogDescription>
{editName && ( {editName && (
<> <>
<Input <Input
@ -113,13 +113,13 @@ export default function ExportCard({
/> />
<DialogFooter> <DialogFooter>
<Button <Button
aria-label="Save Export" aria-label={t("editExport.saveExport")}
size="sm" size="sm"
variant="select" variant="select"
disabled={(editName?.update?.length ?? 0) == 0} disabled={(editName?.update?.length ?? 0) == 0}
onClick={() => submitRename()} onClick={() => submitRename()}
> >
Save {t("button.save", { ns: "common" })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>
@ -207,7 +207,7 @@ export default function ExportCard({
{!exportedRecording.in_progress && ( {!exportedRecording.in_progress && (
<Button <Button
className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white" className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white"
aria-label="Play" aria-label={t("button.play", { ns: "common" })}
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
onSelect(exportedRecording); onSelect(exportedRecording);

View File

@ -3,6 +3,7 @@ import { Button } from "../ui/button";
import { LuRefreshCcw } from "react-icons/lu"; import { LuRefreshCcw } from "react-icons/lu";
import { MutableRefObject, useMemo } from "react"; import { MutableRefObject, useMemo } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type NewReviewDataProps = { type NewReviewDataProps = {
className: string; className: string;
@ -18,6 +19,7 @@ export default function NewReviewData({
itemsToReview, itemsToReview,
pullLatestData, pullLatestData,
}: NewReviewDataProps) { }: NewReviewDataProps) {
const { t } = useTranslation(["views/events"]);
const hasUpdate = useMemo(() => { const hasUpdate = useMemo(() => {
if (!reviewItems || !itemsToReview) { if (!reviewItems || !itemsToReview) {
return false; return false;
@ -36,7 +38,7 @@ export default function NewReviewData({
: "invisible", : "invisible",
"mx-auto bg-gray-400 text-center text-white", "mx-auto bg-gray-400 text-center text-white",
)} )}
aria-label="View new review items" aria-label={t("newReviewItems.label")}
onClick={() => { onClick={() => {
pullLatestData(); pullLatestData();
if (contentRef.current) { if (contentRef.current) {
@ -48,7 +50,7 @@ export default function NewReviewData({
}} }}
> >
<LuRefreshCcw className="mr-2 h-4 w-4" /> <LuRefreshCcw className="mr-2 h-4 w-4" />
New Items To Review {t("newReviewItems.button")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -15,6 +15,7 @@ import { DateRange } from "react-day-picker";
import { useState } from "react"; import { useState } from "react";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { t } from "i18next"; import { t } from "i18next";
import { useTranslation } from "react-i18next";
type CalendarFilterButtonProps = { type CalendarFilterButtonProps = {
reviewSummary?: ReviewSummary; reviewSummary?: ReviewSummary;
@ -28,16 +29,17 @@ export default function CalendarFilterButton({
day, day,
updateSelectedDay, updateSelectedDay,
}: CalendarFilterButtonProps) { }: CalendarFilterButtonProps) {
const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const selectedDate = useFormattedTimestamp( const selectedDate = useFormattedTimestamp(
day == undefined ? 0 : day?.getTime() / 1000 + 1, day == undefined ? 0 : day?.getTime() / 1000 + 1,
t("time.formattedTimestampOnlyMonthAndDay"), t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }),
); );
const trigger = ( const trigger = (
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Select a date to filter by" aria-label={t("date.selectDateBy.label")}
variant={day == undefined ? "default" : "select"} variant={day == undefined ? "default" : "select"}
size="sm" size="sm"
> >
@ -64,7 +66,7 @@ export default function CalendarFilterButton({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex items-center justify-center p-2"> <div className="flex items-center justify-center p-2">
<Button <Button
aria-label="Reset" aria-label={t("button.reset", { ns: "common" })}
onClick={() => { onClick={() => {
updateSelectedDay(undefined); updateSelectedDay(undefined);
}} }}
@ -107,7 +109,7 @@ export function CalendarRangeFilterButton({
const trigger = ( const trigger = (
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Select a date to filter by" aria-label={t("date.selectDateBy.label")}
variant={range == undefined ? "default" : "select"} variant={range == undefined ? "default" : "select"}
size="sm" size="sm"
> >

View File

@ -154,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-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" : "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" size="xs"
onClick={() => (group ? setGroup("default", true) : null)} onClick={() => (group ? setGroup("default", true) : null)}
onMouseEnter={() => (isDesktop ? showTooltip("default") : null)} onMouseEnter={() => (isDesktop ? showTooltip("default") : null)}
@ -179,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-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
: "bg-secondary text-secondary-foreground" : "bg-secondary text-secondary-foreground"
} }
aria-label="Camera Group" aria-label={t("group.label")}
size="xs" size="xs"
onClick={() => setGroup(name, group != "default")} onClick={() => setGroup(name, group != "default")}
onMouseEnter={() => (isDesktop ? showTooltip(name) : null)} onMouseEnter={() => (isDesktop ? showTooltip(name) : null)}
@ -206,7 +206,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
<Button <Button
className="bg-secondary text-muted-foreground" className="bg-secondary text-muted-foreground"
aria-label="Add camera group" aria-label={t("group.add")}
size="xs" size="xs"
onClick={() => setAddGroup(true)} onClick={() => setAddGroup(true)}
> >
@ -278,9 +278,15 @@ function NewGroupDialog({
} else { } else {
setOpen(false); setOpen(false);
setEditState("none"); setEditState("none");
toast.error(`Failed to save config changes: ${res.statusText}`, { toast.error(
position: "top-center", t("toast.save.error", {
}); errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
} }
}) })
.catch((error) => { .catch((error) => {
@ -290,7 +296,7 @@ function NewGroupDialog({
error.response?.data?.message || error.response?.data?.message ||
error.response?.data?.detail || error.response?.data?.detail ||
"Unknown error"; "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, { toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center", position: "top-center",
}); });
}) })
@ -305,6 +311,7 @@ function NewGroupDialog({
setOpen, setOpen,
deleteGroup, deleteGroup,
deleteGridLayout, deleteGridLayout,
t,
], ],
); );
@ -373,7 +380,7 @@ function NewGroupDialog({
"size-6 rounded-md bg-secondary-foreground p-1 text-background", "size-6 rounded-md bg-secondary-foreground p-1 text-background",
isMobile && "text-secondary-foreground", isMobile && "text-secondary-foreground",
)} )}
aria-label="Add camera group" aria-label={t("group.add")}
onClick={() => { onClick={() => {
setEditState("add"); setEditState("add");
}} }}
@ -407,9 +414,7 @@ function NewGroupDialog({
<Title> <Title>
{editState == "add" ? t("group.add") : t("group.edit")} {editState == "add" ? t("group.add") : t("group.edit")}
</Title> </Title>
<Description className="sr-only"> <Description className="sr-only">{t("group.edit")}</Description>
Edit camera groups
</Description>
</Header> </Header>
<CameraGroupEdit <CameraGroupEdit
currentGroups={currentGroups} currentGroups={currentGroups}
@ -563,13 +568,13 @@ export function CameraGroupRow({
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
aria-label="Edit group" aria-label={t("group.edit")}
onClick={onEditGroup} onClick={onEditGroup}
> >
{t("button.edit", { ns: "common" })} {t("button.edit", { ns: "common" })}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
aria-label="Delete group" aria-label={t("group.delete.label")}
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}
> >
{t("button.delete", { ns: "common" })} {t("button.delete", { ns: "common" })}
@ -855,7 +860,7 @@ export function CameraGroupEdit({
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
className="flex h-auto items-center gap-1" className="flex h-auto items-center gap-1"
aria-label="Camera streaming settings" aria-label={t("group.camera.setting.label")}
size="icon" size="icon"
variant="ghost" variant="ghost"
disabled={ disabled={
@ -934,7 +939,7 @@ export function CameraGroupEdit({
<Button <Button
type="button" type="button"
className="flex flex-1" className="flex flex-1"
aria-label="Cancel" aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
> >
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
@ -943,7 +948,7 @@ export function CameraGroupEdit({
variant="select" variant="select"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
type="submit" type="submit"
> >
{isLoading ? ( {isLoading ? (

View File

@ -59,7 +59,7 @@ export function CamerasFilterButton({
const trigger = ( const trigger = (
<Button <Button
className="flex items-center gap-2 capitalize" className="flex items-center gap-2 capitalize"
aria-label="Cameras Filter" aria-label={t("cameras.label")}
variant={selectedCameras?.length == undefined ? "default" : "select"} variant={selectedCameras?.length == undefined ? "default" : "select"}
size="sm" size="sm"
> >
@ -228,7 +228,7 @@ export function CamerasFilterContent({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
aria-label="Apply" aria-label={t("button.apply", { ns: "common" })}
variant="select" variant="select"
disabled={currentCameras?.length === 0} disabled={currentCameras?.length === 0}
onClick={() => { onClick={() => {
@ -239,7 +239,7 @@ export function CamerasFilterContent({
{t("button.apply", { ns: "common" })} {t("button.apply", { ns: "common" })}
</Button> </Button>
<Button <Button
aria-label="Reset" aria-label={t("button.reset", { ns: "common" })}
onClick={() => { onClick={() => {
setCurrentCameras(undefined); setCurrentCameras(undefined);
updateCameraFilter(undefined); updateCameraFilter(undefined);

View File

@ -9,6 +9,8 @@ import { Switch } from "../ui/switch";
import { DropdownMenuSeparator } from "../ui/dropdown-menu"; import { DropdownMenuSeparator } from "../ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import FilterSwitch from "./FilterSwitch"; import FilterSwitch from "./FilterSwitch";
import { useTranslation } from "react-i18next";
import { t } from "i18next";
type LogSettingsButtonProps = { type LogSettingsButtonProps = {
selectedLabels?: LogSeverity[]; selectedLabels?: LogSeverity[];
@ -22,23 +24,26 @@ export function LogSettingsButton({
logSettings, logSettings,
setLogSettings, setLogSettings,
}: LogSettingsButtonProps) { }: LogSettingsButtonProps) {
const { t } = useTranslation(["components/filter"]);
const trigger = ( const trigger = (
<Button <Button
size="sm" size="sm"
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Filter log level" aria-label={t("logSettings.label")}
> >
<FaCog className="text-secondary-foreground" /> <FaCog className="text-secondary-foreground" />
<div className="hidden text-primary md:block">Settings</div> <div className="hidden text-primary md:block">
{t("menu.settings", { ns: "common" })}
</div>
</Button> </Button>
); );
const content = ( const content = (
<div className={cn("my-3 space-y-3 py-3 md:mt-0 md:py-0")}> <div className={cn("my-3 space-y-3 py-3 md:mt-0 md:py-0")}>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="text-md">Filter</div> <div className="text-md">{t("filter")}</div>
<div className="space-y-1 text-xs text-muted-foreground"> <div className="space-y-1 text-xs text-muted-foreground">
Filter logs by severity. {t("logSettings.filterBySeverity")}
</div> </div>
</div> </div>
<GeneralFilterContent <GeneralFilterContent
@ -49,14 +54,13 @@ export function LogSettingsButton({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="text-md">Loading</div> <div className="text-md">{t("logSettings.loading")}</div>
<div className="mt-2.5 flex flex-col gap-2.5"> <div className="mt-2.5 flex flex-col gap-2.5">
<div className="space-y-1 text-xs text-muted-foreground"> <div className="space-y-1 text-xs text-muted-foreground">
When the log pane is scrolled to the bottom, new logs {t("logSettings.loading.desc")}
automatically stream as they are added.
</div> </div>
<FilterSwitch <FilterSwitch
label="Disable log streaming" label={t("logSettings.disableLogStreaming")}
isChecked={logSettings?.disableStreaming ?? false} isChecked={logSettings?.disableStreaming ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
setLogSettings({ setLogSettings({
@ -105,7 +109,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary" className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels" htmlFor="allLabels"
> >
All Logs {t("logSettings.allLogs")}
</Label> </Label>
<Switch <Switch
className="ml-1" className="ml-1"

View File

@ -16,6 +16,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "../ui/alert-dialog"; } from "../ui/alert-dialog";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next";
type ReviewActionGroupProps = { type ReviewActionGroupProps = {
selectedReviews: string[]; selectedReviews: string[];
@ -29,6 +30,7 @@ export default function ReviewActionGroup({
onExport, onExport,
pullLatestData, pullLatestData,
}: ReviewActionGroupProps) { }: ReviewActionGroupProps) {
const { t } = useTranslation(["components/dialog"]);
const onClearSelected = useCallback(() => { const onClearSelected = useCallback(() => {
setSelectedReviews([]); setSelectedReviews([]);
}, [setSelectedReviews]); }, [setSelectedReviews]);
@ -68,22 +70,24 @@ export default function ReviewActionGroup({
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle> <AlertDialogTitle>
{t("recording.confirmDelete.title")}
</AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete all recorded video associated with <Trans ns="components/dialog">
the selected review items? recording.confirmDelete.desc.selected
<br /> </Trans>
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
</AlertDialogDescription> </AlertDialogDescription>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className={buttonVariants({ variant: "destructive" })} className={buttonVariants({ variant: "destructive" })}
onClick={onDelete} onClick={onDelete}
> >
Delete {t("button.delete", { ns: "common" })}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -97,14 +101,14 @@ export default function ReviewActionGroup({
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary" className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected} onClick={onClearSelected}
> >
Unselect {t("button.unselect", { ns: "common" })}
</div> </div>
</div> </div>
<div className="flex items-center gap-1 md:gap-2"> <div className="flex items-center gap-1 md:gap-2">
{selectedReviews.length == 1 && ( {selectedReviews.length == 1 && (
<Button <Button
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
aria-label="Export" aria-label={t("recording.button.export")}
size="sm" size="sm"
onClick={() => { onClick={() => {
onExport(selectedReviews[0]); onExport(selectedReviews[0]);
@ -112,28 +116,38 @@ export default function ReviewActionGroup({
}} }}
> >
<FaCompactDisc className="text-secondary-foreground" /> <FaCompactDisc className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Export</div>} {isDesktop && (
<div className="text-primary">
{t("recording.button.export")}
</div>
)}
</Button> </Button>
)} )}
<Button <Button
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
aria-label="Mark as reviewed" aria-label={t("recording.button.markAsReviewed")}
size="sm" size="sm"
onClick={onMarkAsReviewed} onClick={onMarkAsReviewed}
> >
<FaCircleCheck className="text-secondary-foreground" /> <FaCircleCheck className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Mark as reviewed</div>} {isDesktop && (
<div className="text-primary">
{t("recording.button.markAsReviewed")}
</div>
)}
</Button> </Button>
<Button <Button
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
aria-label="Delete" aria-label={t("button.delete", { ns: "common" })}
size="sm" size="sm"
onClick={handleDelete} onClick={handleDelete}
> >
<HiTrash className="text-secondary-foreground" /> <HiTrash className="text-secondary-foreground" />
{isDesktop && ( {isDesktop && (
<div className="text-primary"> <div className="text-primary">
{bypassDialog ? "Delete Now" : "Delete"} {bypassDialog
? t("recording.button.deleteNow")
: t("button.delete", { ns: "common" })}
</div> </div>
)} )}
</Button> </Button>

View File

@ -286,7 +286,7 @@ function ShowReviewFilter({
<Button <Button
className="block duration-0 md:hidden" className="block duration-0 md:hidden"
aria-label="Show reviewed" aria-label={t("review.showReviewed")}
variant={showReviewedSwitch ? "select" : "default"} variant={showReviewedSwitch ? "select" : "default"}
size="sm" size="sm"
onClick={() => onClick={() =>
@ -351,7 +351,7 @@ function GeneralFilterButton({
selectedLabels?.length || selectedZones?.length ? "select" : "default" selectedLabels?.length || selectedZones?.length ? "select" : "default"
} }
className="flex items-center gap-2 capitalize" className="flex items-center gap-2 capitalize"
aria-label="Filter" aria-label={t("filter")}
> >
<FaFilter <FaFilter
className={`${ className={`${
@ -572,7 +572,7 @@ export function GeneralFilterContent({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
aria-label="Apply" aria-label={t("button.apply", { ns: "common" })}
variant="select" variant="select"
onClick={() => { onClick={() => {
onApply(); onApply();
@ -581,7 +581,10 @@ export function GeneralFilterContent({
> >
{t("button.apply", { ns: "common" })} {t("button.apply", { ns: "common" })}
</Button> </Button>
<Button aria-label="Reset" onClick={onReset}> <Button
aria-label={t("button.reset", { ns: "common" })}
onClick={onReset}
>
{t("button.reset", { ns: "common" })} {t("button.reset", { ns: "common" })}
</Button> </Button>
</div> </div>
@ -624,7 +627,7 @@ function ShowMotionOnlyButton({
<Button <Button
size="sm" size="sm"
className="duration-0" className="duration-0"
aria-label="Show Motion Only" aria-label={t("motion.showMotionOnly", { ns: "components/filter" })}
variant={motionOnlyButton ? "select" : "default"} variant={motionOnlyButton ? "select" : "default"}
onClick={() => setMotionOnlyButton(!motionOnlyButton)} onClick={() => setMotionOnlyButton(!motionOnlyButton)}
> >

View File

@ -15,6 +15,7 @@ import {
} from "../ui/alert-dialog"; } from "../ui/alert-dialog";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { toast } from "sonner"; import { toast } from "sonner";
import { Trans, useTranslation } from "react-i18next";
type SearchActionGroupProps = { type SearchActionGroupProps = {
selectedObjects: string[]; selectedObjects: string[];
@ -26,6 +27,7 @@ export default function SearchActionGroup({
setSelectedObjects, setSelectedObjects,
pullLatestData, pullLatestData,
}: SearchActionGroupProps) { }: SearchActionGroupProps) {
const { t } = useTranslation(["views/filter"]);
const onClearSelected = useCallback(() => { const onClearSelected = useCallback(() => {
setSelectedObjects([]); setSelectedObjects([]);
}, [setSelectedObjects]); }, [setSelectedObjects]);
@ -37,7 +39,7 @@ export default function SearchActionGroup({
}) })
.then((resp) => { .then((resp) => {
if (resp.status == 200) { if (resp.status == 200) {
toast.success("Tracked objects deleted successfully.", { toast.success(t("trackedObjectDelete.toast.success"), {
position: "top-center", position: "top-center",
}); });
setSelectedObjects([]); setSelectedObjects([]);
@ -49,11 +51,11 @@ export default function SearchActionGroup({
error.response?.data?.message || error.response?.data?.message ||
error.response?.data?.detail || error.response?.data?.detail ||
"Unknown error"; "Unknown error";
toast.error(`Failed to delete tracked objects.: ${errorMessage}`, { toast.error(t("trackedObjectDelete.toast.error", { errorMessage }), {
position: "top-center", position: "top-center",
}); });
}); });
}, [selectedObjects, setSelectedObjects, pullLatestData]); }, [selectedObjects, setSelectedObjects, pullLatestData, t]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bypassDialog, setBypassDialog] = useState(false); const [bypassDialog, setBypassDialog] = useState(false);
@ -78,27 +80,27 @@ export default function SearchActionGroup({
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle> <AlertDialogTitle>
{t("trackedObjectDelete.title")}
</AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogDescription> <AlertDialogDescription>
Deleting these {selectedObjects.length} tracked objects removes the <Trans
snapshot, any saved embeddings, and any associated object lifecycle ns="components/filter"
entries. Recorded footage of these tracked objects in History view values={{ objectLength: selectedObjects.length }}
will <em>NOT</em> be deleted. >
<br /> trackedObjectDelete.desc
<br /> </Trans>
Are you sure you want to proceed?
<br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
</AlertDialogDescription> </AlertDialogDescription>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className={buttonVariants({ variant: "destructive" })} className={buttonVariants({ variant: "destructive" })}
onClick={onDelete} onClick={onDelete}
> >
Delete {t("button.delete", { ns: "common" })}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -112,20 +114,22 @@ export default function SearchActionGroup({
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary" className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected} onClick={onClearSelected}
> >
Unselect {t("button.unselect", { ns: "common" })}
</div> </div>
</div> </div>
<div className="flex items-center gap-1 md:gap-2"> <div className="flex items-center gap-1 md:gap-2">
<Button <Button
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
aria-label="Delete" aria-label={t("button.delete", { ns: "common" })}
size="sm" size="sm"
onClick={handleDelete} onClick={handleDelete}
> >
<HiTrash className="text-secondary-foreground" /> <HiTrash className="text-secondary-foreground" />
{isDesktop && ( {isDesktop && (
<div className="text-primary"> <div className="text-primary">
{bypassDialog ? "Delete Now" : "Delete"} {bypassDialog
? t("button.deleteNow", { ns: "common" })
: t("button.delete", { ns: "common" })}
</div> </div>
)} )}
</Button> </Button>

View File

@ -253,7 +253,6 @@ function GeneralFilterButton({
return t("labels.count", { return t("labels.count", {
count: selectedLabels.length, count: selectedLabels.length,
ns: "components/filter",
}); });
}, [selectedLabels, t]); }, [selectedLabels, t]);
@ -270,7 +269,7 @@ function GeneralFilterButton({
size="sm" size="sm"
variant={selectedLabels?.length ? "select" : "default"} variant={selectedLabels?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize" className="flex items-center gap-2 capitalize"
aria-label="Labels" aria-label={t("labels.label")}
> >
<MdLabel <MdLabel
className={`${selectedLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`} className={`${selectedLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
@ -381,7 +380,7 @@ export function GeneralFilterContent({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
aria-label="Apply" aria-label={t("button.apply", { ns: "common" })}
variant="select" variant="select"
onClick={() => { onClick={() => {
if (selectedLabels != currentLabels) { if (selectedLabels != currentLabels) {
@ -394,7 +393,7 @@ export function GeneralFilterContent({
{t("button.apply", { ns: "common" })} {t("button.apply", { ns: "common" })}
</Button> </Button>
<Button <Button
aria-label="Reset" aria-label={t("button.reset", { ns: "common" })}
onClick={() => { onClick={() => {
setCurrentLabels(undefined); setCurrentLabels(undefined);
updateLabelFilter(undefined); updateLabelFilter(undefined);
@ -442,7 +441,7 @@ function SortTypeButton({
: "default" : "default"
} }
className="flex items-center gap-2 capitalize" className="flex items-center gap-2 capitalize"
aria-label="Labels" aria-label={t("labels.label")}
> >
<MdSort <MdSort
className={`${selectedSortType != defaultSortType && selectedSortType != undefined ? "text-selected-foreground" : "text-secondary-foreground"}`} className={`${selectedSortType != defaultSortType && selectedSortType != undefined ? "text-selected-foreground" : "text-secondary-foreground"}`}
@ -557,7 +556,7 @@ export function SortTypeContent({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
aria-label="Apply" aria-label={t("button.apply", { ns: "common" })}
variant="select" variant="select"
onClick={() => { onClick={() => {
if (selectedSortType != currentSortType) { if (selectedSortType != currentSortType) {
@ -570,7 +569,7 @@ export function SortTypeContent({
{t("button.apply", { ns: "common" })} {t("button.apply", { ns: "common" })}
</Button> </Button>
<Button <Button
aria-label="Reset" aria-label={t("button.reset", { ns: "common" })}
onClick={() => { onClick={() => {
setCurrentSortType(undefined); setCurrentSortType(undefined);
updateSortType(undefined); updateSortType(undefined);

View File

@ -23,7 +23,7 @@ export function ZoneMaskFilterButton({
size="sm" size="sm"
variant={selectedZoneMask?.length ? "select" : "default"} variant={selectedZoneMask?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize" className="flex items-center gap-2 capitalize"
aria-label="Filter by zone mask" aria-label={t("zoneMask.filterBy")}
> >
<FaFilter <FaFilter
className={`${selectedZoneMask?.length ? "text-selected-foreground" : "text-secondary-foreground"}`} className={`${selectedZoneMask?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
@ -31,7 +31,7 @@ export function ZoneMaskFilterButton({
<div <div
className={`hidden md:block ${selectedZoneMask?.length ? "text-selected-foreground" : "text-primary"}`} className={`hidden md:block ${selectedZoneMask?.length ? "text-selected-foreground" : "text-primary"}`}
> >
{t("label")} {t("filter")}
</div> </div>
</Button> </Button>
); );

View File

@ -205,11 +205,15 @@ export function CombinedStorageGraph({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
className="focus:outline-none" className="focus:outline-none"
aria-label="Unused Storage Information" aria-label={t(
"storage.cameraStorage.unusedStorageInformation",
)}
> >
<CiCircleAlert <CiCircleAlert
className="size-5" className="size-5"
aria-label="Unused Storage Information" aria-label={t(
"storage.cameraStorage.unusedStorageInformation",
)}
/> />
</button> </button>
</PopoverTrigger> </PopoverTrigger>

View File

@ -71,7 +71,7 @@ export default function IconPicker({
{!selectedIcon?.name || !selectedIcon?.Icon ? ( {!selectedIcon?.name || !selectedIcon?.Icon ? (
<Button <Button
className="mt-2 w-full text-muted-foreground" className="mt-2 w-full text-muted-foreground"
aria-label="Select an icon" aria-label={t("iconPicker.selectIcon")}
> >
{t("iconPicker.selectIcon")} {t("iconPicker.selectIcon")}
</Button> </Button>

View File

@ -79,14 +79,17 @@ export function SaveSearchDialog({
</div> </div>
)} )}
<DialogFooter> <DialogFooter>
<Button aria-label="Cancel" onClick={onClose}> <Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onClose}
>
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
variant="select" variant="select"
className="mb-2 md:mb-0" className="mb-2 md:mb-0"
aria-label="Save this search" aria-label={t("search.saveSearch.button.save.label")}
> >
{t("button.save", { ns: "common" })} {t("button.save", { ns: "common" })}
</Button> </Button>

View File

@ -117,7 +117,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
className={ className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
} }
aria-label="Set Password" aria-label={t("menu.user.setPassword")}
onClick={() => setPasswordDialogOpen(true)} onClick={() => setPasswordDialogOpen(true)}
> >
<LuSquarePen className="mr-2 size-4" /> <LuSquarePen className="mr-2 size-4" />
@ -128,7 +128,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
className={ className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
} }
aria-label="Log out" aria-label={t("menu.user.logout")}
> >
<a className="flex" href={logoutUrl}> <a className="flex" href={logoutUrl}>
<LuLogOut className="mr-2 size-4" /> <LuLogOut className="mr-2 size-4" />

View File

@ -182,7 +182,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
aria-label="Set Password" aria-label={t("menu.user.setPassword")}
onClick={() => setPasswordDialogOpen(true)} onClick={() => setPasswordDialogOpen(true)}
> >
<LuSquarePen className="mr-2 size-4" /> <LuSquarePen className="mr-2 size-4" />
@ -195,7 +195,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
aria-label="Log out" aria-label={t("menu.user.logout", { ns: "common" })}
> >
<a className="flex" href={logoutUrl}> <a className="flex" href={logoutUrl}>
<LuLogOut className="mr-2 size-4" /> <LuLogOut className="mr-2 size-4" />
@ -216,7 +216,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex w-full items-center p-2 text-sm" : "flex w-full items-center p-2 text-sm"
} }
aria-label="System metrics" aria-label={t("menu.systemMetrics")}
> >
<LuActivity className="mr-2 size-4" /> <LuActivity className="mr-2 size-4" />
<span>{t("menu.systemMetrics")}</span> <span>{t("menu.systemMetrics")}</span>
@ -229,7 +229,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex w-full items-center p-2 text-sm" : "flex w-full items-center p-2 text-sm"
} }
aria-label="System logs" aria-label={t("menu.systemLogs")}
> >
<LuList className="mr-2 size-4" /> <LuList className="mr-2 size-4" />
<span>{t("menu.systemLogs")}</span> <span>{t("menu.systemLogs")}</span>
@ -252,7 +252,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex w-full items-center p-2 text-sm" : "flex w-full items-center p-2 text-sm"
} }
aria-label="Settings" aria-label={t("menu.settings")}
> >
<LuSettings className="mr-2 size-4" /> <LuSettings className="mr-2 size-4" />
<span>{t("menu.settings")}</span> <span>{t("menu.settings")}</span>
@ -267,7 +267,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex w-full items-center p-2 text-sm" : "flex w-full items-center p-2 text-sm"
} }
aria-label="Configuration editor" aria-label={t("menu.configurationEditor")}
> >
<LuSquarePen className="mr-2 size-4" /> <LuSquarePen className="mr-2 size-4" />
<span>{t("menu.configurationEditor")}</span> <span>{t("menu.configurationEditor")}</span>
@ -340,7 +340,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
aria-label="Use the system settings for language" aria-label={t("menu.language.withSystem.label")}
onClick={() => setLanguage(systemLanguage)} onClick={() => setLanguage(systemLanguage)}
> >
{language === systemLanguage ? ( {language === systemLanguage ? (
@ -377,7 +377,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
aria-label="Light mode" aria-label={t("menu.darkMode.light")}
onClick={() => setTheme("light")} onClick={() => setTheme("light")}
> >
{theme === "light" ? ( {theme === "light" ? (
@ -397,7 +397,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
aria-label="Dark mode" aria-label={t("menu.darkMode.dark")}
onClick={() => setTheme("dark")} onClick={() => setTheme("dark")}
> >
{theme === "dark" ? ( {theme === "dark" ? (
@ -417,7 +417,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
aria-label="Use the system settings for light or dark mode" aria-label={t("menu.darkMode.withSystem.label")}
onClick={() => setTheme("system")} onClick={() => setTheme("system")}
> >
{theme === "system" ? ( {theme === "system" ? (
@ -514,7 +514,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
aria-label="Restart Frigate" aria-label={t("menu.restart")}
onClick={() => setRestartDialogOpen(true)} onClick={() => setRestartDialogOpen(true)}
> >
<LuRotateCw className="mr-2 size-4" /> <LuRotateCw className="mr-2 size-4" />

View File

@ -150,7 +150,7 @@ export default function SearchResultActions({
</MenuItem> </MenuItem>
)} )}
<MenuItem <MenuItem
aria-label="Delete this tracked object" aria-label={t("itemMenu.deleteTrackedObject.label")}
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}
> >
<LuTrash2 className="mr-2 size-4" /> <LuTrash2 className="mr-2 size-4" />

View File

@ -5,6 +5,7 @@ import { IoMdArrowRoundBack } from "react-icons/io";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isPWA } from "@/utils/isPWA"; import { isPWA } from "@/utils/isPWA";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { t } from "i18next";
const MobilePageContext = createContext<{ const MobilePageContext = createContext<{
open: boolean; open: boolean;
@ -160,7 +161,7 @@ export function MobilePageHeader({
> >
<Button <Button
className="absolute left-0 rounded-lg" className="absolute left-0 rounded-lg"
aria-label="Go back" aria-label={t("label.back", { ns: "common" })}
size="sm" size="sm"
onClick={handleClose} onClick={handleClose}
> >

View File

@ -181,7 +181,7 @@ export default function CameraInfoDialog({
<DialogFooter> <DialogFooter>
<Button <Button
variant="select" variant="select"
aria-label="Copy" aria-label={t("button.copy", { ns: "common" })}
onClick={() => onCopyFfprobe()} onClick={() => onCopyFfprobe()}
> >
{t("button.copy", { ns: "common" })} {t("button.copy", { ns: "common" })}

View File

@ -256,7 +256,7 @@ export default function CreateUserDialog({
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Cancel" aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading} disabled={isLoading}
onClick={handleCancel} onClick={handleCancel}
type="button" type="button"
@ -265,7 +265,7 @@ export default function CreateUserDialog({
</Button> </Button>
<Button <Button
variant="select" variant="select"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid} disabled={isLoading || !form.formState.isValid}
className="flex flex-1" className="flex flex-1"
type="submit" type="submit"

View File

@ -44,7 +44,7 @@ export default function DeleteUserDialog({
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Cancel" aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
type="button" type="button"
> >
@ -52,7 +52,7 @@ export default function DeleteUserDialog({
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
aria-label="Delete" aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1" className="flex flex-1"
onClick={onDelete} onClick={onDelete}
> >

View File

@ -151,7 +151,7 @@ export default function ExportDialog({
<Trigger asChild> <Trigger asChild>
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Export" aria-label={t("menu.export", { ns: "common" })}
size="sm" size="sm"
onClick={() => { onClick={() => {
const now = new Date(latestTime * 1000); const now = new Date(latestTime * 1000);
@ -327,7 +327,7 @@ export function ExportContent({
</div> </div>
<Button <Button
className={isDesktop ? "" : "w-full"} className={isDesktop ? "" : "w-full"}
aria-label="Select or export" aria-label={t("export.selectOrExport")}
variant="select" variant="select"
size="sm" size="sm"
onClick={() => { onClick={() => {
@ -444,7 +444,7 @@ function CustomTimeSelector({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`} className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label="Start time" aria-label={t("export.time.start")}
variant={startOpen ? "select" : "default"} variant={startOpen ? "select" : "default"}
size="sm" size="sm"
onClick={() => { onClick={() => {
@ -510,7 +510,7 @@ function CustomTimeSelector({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`} className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label="End time" aria-label={t("export.time.end")}
variant={endOpen ? "select" : "default"} variant={endOpen ? "select" : "default"}
size="sm" size="sm"
onClick={() => { onClick={() => {

View File

@ -11,6 +11,7 @@ import { GpuInfo, Nvinfo, Vainfo } from "@/types/stats";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next";
type GPUInfoDialogProps = { type GPUInfoDialogProps = {
showGpuInfo: boolean; showGpuInfo: boolean;
@ -22,6 +23,8 @@ export default function GPUInfoDialog({
gpuType, gpuType,
setShowGpuInfo, setShowGpuInfo,
}: GPUInfoDialogProps) { }: GPUInfoDialogProps) {
const { t } = useTranslation(["views/system"]);
const { data: vainfo } = useSWR<Vainfo>( const { data: vainfo } = useSWR<Vainfo>(
showGpuInfo && gpuType == "vainfo" ? "vainfo" : null, showGpuInfo && gpuType == "vainfo" ? "vainfo" : null,
); );
@ -43,13 +46,23 @@ export default function GPUInfoDialog({
<Dialog open={showGpuInfo} onOpenChange={setShowGpuInfo}> <Dialog open={showGpuInfo} onOpenChange={setShowGpuInfo}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Vainfo Output</DialogTitle> <DialogTitle>
{t("general.hardwareInfo.gpuInfo.vainfoOutput.title")}
</DialogTitle>
</DialogHeader> </DialogHeader>
{vainfo ? ( {vainfo ? (
<div className="scrollbar-container mb-2 max-h-96 overflow-y-scroll whitespace-pre-line"> <div className="scrollbar-container mb-2 max-h-96 overflow-y-scroll whitespace-pre-line">
<div>Return Code: {vainfo.return_code}</div> <div>
{t("general.hardwareInfo.gpuInfo.vainfoOutput.returnCode", {
code: vainfo.return_code,
})}
</div>
<br /> <br />
<div>Process {vainfo.return_code == 0 ? "Output" : "Error"}:</div> <div>
{vainfo.return_code == 0
? t("general.hardwareInfo.gpuInfo.vainfoOutput.processOutput")
: t("general.hardwareInfo.gpuInfo.vainfoOutput.processError")}
</div>
<br /> <br />
<div> <div>
{vainfo.return_code == 0 ? vainfo.stdout : vainfo.stderr} {vainfo.return_code == 0 ? vainfo.stdout : vainfo.stderr}
@ -60,17 +73,17 @@ export default function GPUInfoDialog({
)} )}
<DialogFooter> <DialogFooter>
<Button <Button
aria-label="Close GPU info" aria-label={t("general.hardwareInfo.gpuInfo.closeInfo.label")}
onClick={() => setShowGpuInfo(false)} onClick={() => setShowGpuInfo(false)}
> >
Close {t("button.close", { ns: "common" })}
</Button> </Button>
<Button <Button
aria-label="Copy GPU info" aria-label={t("general.hardwareInfo.gpuInfo.copyInfo.label")}
variant="select" variant="select"
onClick={() => onCopyInfo()} onClick={() => onCopyInfo()}
> >
Copy {t("button.copy", { ns: "common" })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -81,34 +94,52 @@ export default function GPUInfoDialog({
<Dialog open={showGpuInfo} onOpenChange={setShowGpuInfo}> <Dialog open={showGpuInfo} onOpenChange={setShowGpuInfo}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Nvidia SMI Output</DialogTitle> <DialogTitle>
{t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.title")}
</DialogTitle>
</DialogHeader> </DialogHeader>
{nvinfo ? ( {nvinfo ? (
<div className="scrollbar-container mb-2 max-h-96 overflow-y-scroll whitespace-pre-line"> <div className="scrollbar-container mb-2 max-h-96 overflow-y-scroll whitespace-pre-line">
<div>Name: {nvinfo["0"].name}</div> <div>
{t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", {
name: nvinfo["0"].name,
})}
</div>
<br /> <br />
<div>Driver: {nvinfo["0"].driver}</div> <div>
{t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", {
name: nvinfo["0"].driver,
})}
</div>
<br /> <br />
<div>Cuda Compute Capability: {nvinfo["0"].cuda_compute}</div> <div>
{t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", {
name: nvinfo["0"].cuda_compute,
})}
</div>
<br /> <br />
<div>VBios Info: {nvinfo["0"].vbios}</div> <div>
{t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", {
name: nvinfo["0"].vbios,
})}
</div>
</div> </div>
) : ( ) : (
<ActivityIndicator /> <ActivityIndicator />
)} )}
<DialogFooter> <DialogFooter>
<Button <Button
aria-label="Close GPU info" aria-label={t("general.hardwareInfo.gpuInfo.closeInfo.label")}
onClick={() => setShowGpuInfo(false)} onClick={() => setShowGpuInfo(false)}
> >
Close {t("button.close", { ns: "common" })}
</Button> </Button>
<Button <Button
aria-label="Copy GPU info" aria-label={t("general.hardwareInfo.gpuInfo.copyInfo.label")}
variant="select" variant="select"
onClick={() => onCopyInfo()} onClick={() => onCopyInfo()}
> >
Copy {t("button.copy", { ns: "common" })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -3,6 +3,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { FaVideo } from "react-icons/fa"; import { FaVideo } from "react-icons/fa";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { t } from "i18next";
type MobileCameraDrawerProps = { type MobileCameraDrawerProps = {
allCameras: string[]; allCameras: string[];
@ -25,7 +26,7 @@ export default function MobileCameraDrawer({
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button <Button
className="rounded-lg capitalize" className="rounded-lg capitalize"
aria-label="Cameras" aria-label={t("menu.live.cameras")}
size="sm" size="sm"
> >
<FaVideo className="text-secondary-foreground" /> <FaVideo className="text-secondary-foreground" />

View File

@ -149,7 +149,7 @@ export default function MobileReviewSettingsDrawer({
{features.includes("export") && ( {features.includes("export") && (
<Button <Button
className="flex w-full items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2"
aria-label="Export" aria-label={t("export")}
onClick={() => { onClick={() => {
setDrawerMode("export"); setDrawerMode("export");
setMode("select"); setMode("select");
@ -162,7 +162,7 @@ export default function MobileReviewSettingsDrawer({
{features.includes("calendar") && ( {features.includes("calendar") && (
<Button <Button
className="flex w-full items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2"
aria-label="Calendar" aria-label={t("calendar")}
variant={filter?.after ? "select" : "default"} variant={filter?.after ? "select" : "default"}
onClick={() => setDrawerMode("calendar")} onClick={() => setDrawerMode("calendar")}
> >
@ -175,7 +175,7 @@ export default function MobileReviewSettingsDrawer({
{features.includes("filter") && ( {features.includes("filter") && (
<Button <Button
className="flex w-full items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2"
aria-label="Filter" aria-label={t("filter")}
variant={filter?.labels || filter?.zones ? "select" : "default"} variant={filter?.labels || filter?.zones ? "select" : "default"}
onClick={() => setDrawerMode("filter")} onClick={() => setDrawerMode("filter")}
> >
@ -247,7 +247,7 @@ export default function MobileReviewSettingsDrawer({
<SelectSeparator /> <SelectSeparator />
<div className="flex items-center justify-center p-2"> <div className="flex items-center justify-center p-2">
<Button <Button
aria-label="Reset" aria-label={t("button.reset", { ns: "common" })}
onClick={() => { onClick={() => {
onUpdateFilter({ onUpdateFilter({
...filter, ...filter,
@ -272,7 +272,7 @@ export default function MobileReviewSettingsDrawer({
{t("button.back", { ns: "common" })} {t("button.back", { ns: "common" })}
</div> </div>
<div className="absolute left-1/2 -translate-x-1/2 text-muted-foreground"> <div className="absolute left-1/2 -translate-x-1/2 text-muted-foreground">
Filter {t("filter")}
</div> </div>
</div> </div>
<GeneralFilterContent <GeneralFilterContent
@ -326,7 +326,7 @@ export default function MobileReviewSettingsDrawer({
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button <Button
className="rounded-lg capitalize" className="rounded-lg capitalize"
aria-label="Filters" aria-label={t("filters")}
variant={ variant={
filter?.labels || filter?.after || filter?.zones filter?.labels || filter?.after || filter?.zones
? "select" ? "select"

View File

@ -86,7 +86,7 @@ export default function RoleChangeDialog({
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Cancel" aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
type="button" type="button"
> >
@ -94,7 +94,7 @@ export default function RoleChangeDialog({
</Button> </Button>
<Button <Button
variant="select" variant="select"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
className="flex flex-1" className="flex flex-1"
onClick={() => onSave(selectedRole)} onClick={() => onSave(selectedRole)}
disabled={selectedRole === currentRole} disabled={selectedRole === currentRole}

View File

@ -30,7 +30,7 @@ export default function SaveExportOverlay({
> >
<Button <Button
className="flex items-center gap-1 text-primary" className="flex items-center gap-1 text-primary"
aria-label="Cancel" aria-label={t("button.cancel", { ns: "common" })}
size="sm" size="sm"
onClick={onCancel} onClick={onCancel}
> >
@ -39,7 +39,7 @@ export default function SaveExportOverlay({
</Button> </Button>
<Button <Button
className="flex items-center gap-1" className="flex items-center gap-1"
aria-label="Preview export" aria-label={t("export.fromTimeline.previewExport")}
size="sm" size="sm"
onClick={onPreview} onClick={onPreview}
> >
@ -48,13 +48,13 @@ export default function SaveExportOverlay({
</Button> </Button>
<Button <Button
className="flex items-center gap-1" className="flex items-center gap-1"
aria-label="Save export" aria-label={t("export.fromTimeline.saveExport")}
variant="select" variant="select"
size="sm" size="sm"
onClick={onSave} onClick={onSave}
> >
<FaCompactDisc /> <FaCompactDisc />
{t("export.fromTimeline.saveExport", { ns: "components/dialog" })} {t("export.fromTimeline.saveExport")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -201,7 +201,7 @@ export default function SetPasswordDialog({
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Cancel" aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
type="button" type="button"
> >
@ -209,7 +209,7 @@ export default function SetPasswordDialog({
</Button> </Button>
<Button <Button
variant="select" variant="select"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
className="flex flex-1" className="flex flex-1"
onClick={handleSave} onClick={handleSave}
disabled={!password || password !== confirmPassword} disabled={!password || password !== confirmPassword}

View File

@ -26,6 +26,7 @@ import { Button } from "@/components/ui/button";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Trans, useTranslation } from "react-i18next";
type AnnotationSettingsPaneProps = { type AnnotationSettingsPaneProps = {
event: Event; event: Event;
@ -41,6 +42,8 @@ export function AnnotationSettingsPane({
annotationOffset, annotationOffset,
setAnnotationOffset, setAnnotationOffset,
}: AnnotationSettingsPaneProps) { }: AnnotationSettingsPaneProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config, mutate: updateConfig } = const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config"); useSWR<FrigateConfig>("config");
@ -81,9 +84,15 @@ export function AnnotationSettingsPane({
); );
updateConfig(); updateConfig();
} else { } else {
toast.error(`Failed to save config changes: ${res.statusText}`, { toast.error(
position: "top-center", t("toast.save.error", {
}); errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
} }
}) })
.catch((error) => { .catch((error) => {
@ -91,7 +100,7 @@ export function AnnotationSettingsPane({
error.response?.data?.message || error.response?.data?.message ||
error.response?.data?.detail || error.response?.data?.detail ||
"Unknown error"; "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, { toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center", position: "top-center",
}); });
}) })
@ -99,7 +108,7 @@ export function AnnotationSettingsPane({
setIsLoading(false); setIsLoading(false);
}); });
}, },
[updateConfig, config, event], [updateConfig, config, event, t],
); );
function onSubmit(values: z.infer<typeof formSchema>) { function onSubmit(values: z.infer<typeof formSchema>) {
@ -126,7 +135,7 @@ export function AnnotationSettingsPane({
return ( return (
<div className="mb-3 space-y-3 rounded-lg border border-secondary-foreground bg-background_alt p-2"> <div className="mb-3 space-y-3 rounded-lg border border-secondary-foreground bg-background_alt p-2">
<Heading as="h4" className="my-2"> <Heading as="h4" className="my-2">
Annotation Settings {t("objectLifecycle.annotationSettings.title")}
</Heading> </Heading>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row items-center justify-start gap-2 p-3"> <div className="flex flex-row items-center justify-start gap-2 p-3">
@ -136,11 +145,11 @@ export function AnnotationSettingsPane({
onCheckedChange={setShowZones} onCheckedChange={setShowZones}
/> />
<Label className="cursor-pointer" htmlFor="show-zones"> <Label className="cursor-pointer" htmlFor="show-zones">
Show All Zones {t("objectLifecycle.annotationSettings.showAllZones")}
</Label> </Label>
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Always show zones on frames where objects have entered a zone. {t("objectLifecycle.annotationSettings.showAllZones.desc")}
</div> </div>
</div> </div>
<Separator className="my-2 flex bg-secondary" /> <Separator className="my-2 flex bg-secondary" />
@ -154,17 +163,16 @@ export function AnnotationSettingsPane({
name="annotationOffset" name="annotationOffset"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Annotation Offset</FormLabel> <FormLabel>
{t("objectLifecycle.annotationSettings.offset.label")}
</FormLabel>
<div className="flex flex-col gap-3 md:flex-row-reverse md:gap-8"> <div className="flex flex-col gap-3 md:flex-row-reverse md:gap-8">
<div className="flex flex-row items-center gap-3 rounded-lg bg-destructive/50 p-3 text-sm text-primary-variant md:my-0 md:my-5"> <div className="flex flex-row items-center gap-3 rounded-lg bg-destructive/50 p-3 text-sm text-primary-variant md:my-0 md:my-5">
<PiWarningCircle className="size-24" /> <PiWarningCircle className="size-24" />
<div> <div>
This data comes from your camera's detect feed but is <Trans ns="views/explore">
overlayed on images from the the record feed. It is objectLifecycle.annotationSettings.offset.desc
unlikely that the two streams are perfectly in sync. As a </Trans>
result, the bounding box and the footage will not line up
perfectly. However, the <code>annotation_offset</code>{" "}
field can be used to adjust this.
<div className="mt-2 flex items-center text-primary"> <div className="mt-2 flex items-center text-primary">
<Link <Link
to="https://docs.frigate.video/configuration/reference" to="https://docs.frigate.video/configuration/reference"
@ -172,7 +180,9 @@ export function AnnotationSettingsPane({
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline" className="inline"
> >
Read the documentation{" "} {t(
"objectLifecycle.annotationSettings.offset.documentation",
)}
<LuExternalLink className="ml-2 inline-flex size-3" /> <LuExternalLink className="ml-2 inline-flex size-3" />
</Link> </Link>
</div> </div>
@ -187,16 +197,11 @@ export function AnnotationSettingsPane({
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Milliseconds to offset detect annotations by.{" "} {t(
<em>Default: 0</em> "objectLifecycle.annotationSettings.offset.millisecondsToOffset",
)}
<div className="mt-2"> <div className="mt-2">
TIP: Imagine there is an event clip with a person {t("objectLifecycle.annotationSettings.offset.tips")}
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.
</div> </div>
</FormDescription> </FormDescription>
</div> </div>
@ -210,14 +215,14 @@ export function AnnotationSettingsPane({
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Apply" aria-label={t("button.apply", { ns: "common" })}
onClick={form.handleSubmit(onApply)} onClick={form.handleSubmit(onApply)}
> >
Apply {t("button.apply", { ns: "common" })}
</Button> </Button>
<Button <Button
variant="select" variant="select"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
type="submit" type="submit"
@ -225,10 +230,10 @@ export function AnnotationSettingsPane({
{isLoading ? ( {isLoading ? (
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<ActivityIndicator /> <ActivityIndicator />
<span>Saving...</span> <span>{t("button.saving", { ns: "common" })}</span>
</div> </div>
) : ( ) : (
"Save" t("button.save", { ns: "common" })
)} )}
</Button> </Button>
</div> </div>

View File

@ -54,6 +54,7 @@ import { useNavigate } from "react-router-dom";
import { ObjectPath } from "./ObjectPath"; import { ObjectPath } from "./ObjectPath";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { IoPlayCircleOutline } from "react-icons/io5"; import { IoPlayCircleOutline } from "react-icons/io5";
import { useTranslation } from "react-i18next";
type ObjectLifecycleProps = { type ObjectLifecycleProps = {
className?: string; className?: string;
@ -68,6 +69,8 @@ export default function ObjectLifecycle({
fullscreen = false, fullscreen = false,
setPane, setPane,
}: ObjectLifecycleProps) { }: ObjectLifecycleProps) {
const { t } = useTranslation(["views/explore"]);
const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([ const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([
"timeline", "timeline",
{ {
@ -334,12 +337,16 @@ export default function ObjectLifecycle({
<div className={cn("flex items-center gap-2")}> <div className={cn("flex items-center gap-2")}>
<Button <Button
className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0" className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0"
aria-label="Go back" aria-label={t("label.back", { ns: "common" })}
size="sm" size="sm"
onClick={() => setPane("overview")} onClick={() => setPane("overview")}
> >
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" /> <IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && <div className="text-primary">Back</div>} {isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button> </Button>
</div> </div>
)} )}
@ -361,7 +368,7 @@ export default function ObjectLifecycle({
<div className="relative aspect-video"> <div className="relative aspect-video">
<div className="flex flex-col items-center justify-center p-20 text-center"> <div className="flex flex-col items-center justify-center p-20 text-center">
<LuFolderX className="size-16" /> <LuFolderX className="size-16" />
No image found for this timestamp. {t("objectLifecycle.noImageFound")}
</div> </div>
</div> </div>
)} )}
@ -468,7 +475,9 @@ export default function ObjectLifecycle({
) )
} }
> >
<div className="text-primary">Create Object Mask</div> <div className="text-primary">
{t("objectLifecycle.createObjectMask")}
</div>
</div> </div>
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
@ -477,7 +486,7 @@ export default function ObjectLifecycle({
</div> </div>
<div className="mt-3 flex flex-row items-center justify-between"> <div className="mt-3 flex flex-row items-center justify-between">
<Heading as="h4">Object Lifecycle</Heading> <Heading as="h4">{t("objectLifecycle.title")}</Heading>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<Tooltip> <Tooltip>
@ -485,7 +494,7 @@ export default function ObjectLifecycle({
<Button <Button
variant={showControls ? "select" : "default"} variant={showControls ? "select" : "default"}
className="size-7 p-1.5" className="size-7 p-1.5"
aria-label="Adjust annotation settings" aria-label={t("objectLifecycle.adjustAnnotationSettings")}
> >
<LuSettings <LuSettings
className="size-5" className="size-5"
@ -494,14 +503,16 @@ export default function ObjectLifecycle({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>
<TooltipContent>Adjust annotation settings</TooltipContent> <TooltipContent>
{t("objectLifecycle.adjustAnnotationSettings")}
</TooltipContent>
</TooltipPortal> </TooltipPortal>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div className="flex flex-row items-center justify-between"> <div className="flex flex-row items-center justify-between">
<div className="mb-2 text-sm text-muted-foreground"> <div className="mb-2 text-sm text-muted-foreground">
Scroll to view the significant moments of this object's lifecycle. {t("objectLifecycle.scrollViewTips")}
</div> </div>
<div className="min-w-20 text-right text-sm text-muted-foreground"> <div className="min-w-20 text-right text-sm text-muted-foreground">
{current + 1} of {eventSequence.length} {current + 1} of {eventSequence.length}
@ -509,7 +520,7 @@ export default function ObjectLifecycle({
</div> </div>
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && ( {config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
<div className="-mt-2 mb-2 text-sm text-danger"> <div className="-mt-2 mb-2 text-sm text-danger">
Bounding box positions will be inaccurate for autotracking cameras. {t("objectLifecycle.autoTrackingTips")}
</div> </div>
)} )}
{showControls && ( {showControls && (
@ -559,8 +570,8 @@ export default function ObjectLifecycle({
timezone: config.ui.timezone, timezone: config.ui.timezone,
strftime_fmt: strftime_fmt:
config.ui.time_format == "24hour" config.ui.time_format == "24hour"
? "%d %b %H:%M:%S" ? t("time.formattedTimestamp2.24hour")
: "%m/%d %I:%M:%S%P", : t("time.formattedTimestamp2"),
time_style: "medium", time_style: "medium",
date_style: "medium", date_style: "medium",
})} })}

View File

@ -193,7 +193,7 @@ export default function ReviewDetailDialog({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
aria-label="Share this review item" aria-label={t("details.item.button.share")}
size="sm" size="sm"
onClick={() => onClick={() =>
shareOrCopy(`${baseUrl}review?id=${review.id}`) shareOrCopy(`${baseUrl}review?id=${review.id}`)

View File

@ -677,7 +677,7 @@ function ObjectDetailsTab({
<div className="flex items-start"> <div className="flex items-start">
<Button <Button
className="rounded-r-none border-r-0" className="rounded-r-none border-r-0"
aria-label="Regenerate tracked object description" aria-label={t("details.button.regenerate.label")}
onClick={() => regenerateDescription("thumbnails")} onClick={() => regenerateDescription("thumbnails")}
> >
{t("details.button.regenerate")} {t("details.button.regenerate")}
@ -687,7 +687,7 @@ function ObjectDetailsTab({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
className="rounded-l-none border-l-0 px-2" className="rounded-l-none border-l-0 px-2"
aria-label="Expand regeneration menu" aria-label={t("details.expandRegenerationMenu")}
> >
<FaChevronDown className="size-3" /> <FaChevronDown className="size-3" />
</Button> </Button>
@ -695,14 +695,14 @@ function ObjectDetailsTab({
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
aria-label="Regenerate from snapshot" aria-label={t("details.regenerateFromSnapshot")}
onClick={() => regenerateDescription("snapshot")} onClick={() => regenerateDescription("snapshot")}
> >
{t("details.regenerateFromSnapshot")} {t("details.regenerateFromSnapshot")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
aria-label="Regenerate from thumbnails" aria-label={t("details.regenerateFromThumbnails")}
onClick={() => regenerateDescription("thumbnails")} onClick={() => regenerateDescription("thumbnails")}
> >
{t("details.regenerateFromThumbnails")} {t("details.regenerateFromThumbnails")}
@ -716,7 +716,7 @@ function ObjectDetailsTab({
!config?.cameras[search.camera].genai.enabled) && ( !config?.cameras[search.camera].genai.enabled) && (
<Button <Button
variant="select" variant="select"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
onClick={updateDescription} onClick={updateDescription}
> >
{t("button.save", { ns: "common" })} {t("button.save", { ns: "common" })}
@ -867,7 +867,7 @@ export function ObjectSnapshotTab({
<> <>
<Button <Button
className="bg-success" className="bg-success"
aria-label="Confirm this label for Frigate Plus" aria-label={t("explore.plus.review.true.label")}
onClick={() => { onClick={() => {
setState("uploading"); setState("uploading");
onSubmitToPlus(false); onSubmitToPlus(false);
@ -883,7 +883,7 @@ export function ObjectSnapshotTab({
</Button> </Button>
<Button <Button
className="text-white" className="text-white"
aria-label="Do not confirm this label for Frigate Plus" aria-label={t("explore.plus.review.false.label")}
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
setState("uploading"); setState("uploading");

View File

@ -116,7 +116,7 @@ export default function RestartDialog({
<Button <Button
size="lg" size="lg"
className="mt-5" className="mt-5"
aria-label="Force reload now" aria-label={t("restart.restarting.button")}
onClick={handleForceReload} onClick={handleForceReload}
> >
{t("restart.restarting.button")} {t("restart.restarting.button")}

View File

@ -85,7 +85,7 @@ export default function SearchFilterDialog({
const trigger = ( const trigger = (
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="More Filters" aria-label={t("more")}
size="sm" size="sm"
variant={moreFiltersSelected ? "select" : "default"} variant={moreFiltersSelected ? "select" : "default"}
> >
@ -167,7 +167,7 @@ export default function SearchFilterDialog({
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
variant="select" variant="select"
aria-label="Apply" aria-label={t("button.apply", { ns: "common" })}
onClick={() => { onClick={() => {
if (currentFilter != filter) { if (currentFilter != filter) {
onUpdateFilter(currentFilter); onUpdateFilter(currentFilter);
@ -179,7 +179,7 @@ export default function SearchFilterDialog({
{t("button.apply", { ns: "common" })} {t("button.apply", { ns: "common" })}
</Button> </Button>
<Button <Button
aria-label="Reset filters to default values" aria-label={t("reset.label")}
onClick={() => { onClick={() => {
setCurrentFilter((prevFilter) => ({ setCurrentFilter((prevFilter) => ({
...prevFilter, ...prevFilter,
@ -287,7 +287,9 @@ function TimeRangeFilterContent({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
className={`text-primary ${isDesktop ? "" : "text-xs"} `} className={`text-primary ${isDesktop ? "" : "text-xs"} `}
aria-label="Select Start Time" aria-label={t("export.time.start.label", {
ns: "components/dialog",
})}
variant={startOpen ? "select" : "default"} variant={startOpen ? "select" : "default"}
size="sm" size="sm"
onClick={() => { onClick={() => {
@ -325,7 +327,9 @@ function TimeRangeFilterContent({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`} className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label="Select End Time" aria-label={t("export.time.end.label", {
ns: "components/dialog",
})}
variant={endOpen ? "select" : "default"} variant={endOpen ? "select" : "default"}
size="sm" size="sm"
onClick={() => { onClick={() => {
@ -688,14 +692,14 @@ export function SnapshotClipFilterContent({
> >
<ToggleGroupItem <ToggleGroupItem
value="yes" value="yes"
aria-label="Yes" aria-label={t("button.yes", { ns: "common" })}
className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white" className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white"
> >
{t("button.yes", { ns: "common" })} {t("button.yes", { ns: "common" })}
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem <ToggleGroupItem
value="no" value="no"
aria-label="No" aria-label={t("button.no", { ns: "common" })}
className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white" className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white"
> >
{t("button.no", { ns: "common" })} {t("button.no", { ns: "common" })}
@ -766,14 +770,14 @@ export function SnapshotClipFilterContent({
> >
<ToggleGroupItem <ToggleGroupItem
value="yes" value="yes"
aria-label="Yes" aria-label={t("button.yes", { ns: "common" })}
className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white" className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white"
> >
{t("button.yes", { ns: "common" })} {t("button.yes", { ns: "common" })}
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem <ToggleGroupItem
value="no" value="no"
aria-label="No" aria-label={t("button.no", { ns: "common" })}
className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white" className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white"
> >
{t("button.no", { ns: "common" })} {t("button.no", { ns: "common" })}
@ -821,14 +825,14 @@ export function SnapshotClipFilterContent({
> >
<ToggleGroupItem <ToggleGroupItem
value="yes" value="yes"
aria-label="Yes" aria-label={t("button.yes", { ns: "common" })}
className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white" className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white"
> >
{t("button.yes", { ns: "common" })} {t("button.yes", { ns: "common" })}
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem <ToggleGroupItem
value="no" value="no"
aria-label="No" aria-label={t("button.no", { ns: "common" })}
className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white" className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white"
> >
{t("button.no", { ns: "common" })} {t("button.no", { ns: "common" })}

View File

@ -33,6 +33,7 @@ import {
} from "../ui/alert-dialog"; } from "../ui/alert-dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FaCompress, FaExpand } from "react-icons/fa"; import { FaCompress, FaExpand } from "react-icons/fa";
import { useTranslation } from "react-i18next";
type VideoControls = { type VideoControls = {
volume?: boolean; volume?: boolean;
@ -309,6 +310,8 @@ function FrigatePlusUploadButton({
onUploadFrame, onUploadFrame,
containerRef, containerRef,
}: FrigatePlusUploadButtonProps) { }: FrigatePlusUploadButtonProps) {
const { t } = useTranslation(["components/player"]);
const [videoImg, setVideoImg] = useState<string>(); const [videoImg, setVideoImg] = useState<string>();
return ( return (
@ -346,14 +349,16 @@ function FrigatePlusUploadButton({
className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl" className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl"
> >
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Submit this frame to Frigate+?</AlertDialogTitle> <AlertDialogTitle>{t("submitFrigatePlus.title")}</AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<img className="aspect-video w-full object-contain" src={videoImg} /> <img className="aspect-video w-full object-contain" src={videoImg} />
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogAction className="bg-selected" onClick={onUploadFrame}> <AlertDialogAction className="bg-selected" onClick={onUploadFrame}>
Submit {t("submitFrigatePlus.submit")}
</AlertDialogAction> </AlertDialogAction>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View File

@ -358,14 +358,14 @@ export function CameraStreamingDialog({
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Cancel" aria-label={t("button.cancel", { ns: "common" })}
onClick={handleCancel} onClick={handleCancel}
> >
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
<Button <Button
variant="select" variant="select"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
onClick={handleSave} onClick={handleSave}

View File

@ -176,9 +176,15 @@ export default function MotionMaskEditPane({
); );
updateConfig(); updateConfig();
} else { } else {
toast.error(`Failed to save config changes: ${res.statusText}`, { toast.error(
position: "top-center", t("toast.save.error", {
}); errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
} }
}) })
.catch((error) => { .catch((error) => {
@ -186,7 +192,7 @@ export default function MotionMaskEditPane({
error.response?.data?.message || error.response?.data?.message ||
error.response?.data?.detail || error.response?.data?.detail ||
"Unknown error"; "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, { toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center", position: "top-center",
}); });
}) })
@ -323,14 +329,14 @@ export default function MotionMaskEditPane({
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Cancel" aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
> >
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
<Button <Button
variant="select" variant="select"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
type="submit" type="submit"

View File

@ -364,7 +364,7 @@ export default function ObjectMaskEditPane({
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Cancel" aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
> >
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
@ -373,7 +373,7 @@ export default function ObjectMaskEditPane({
variant="select" variant="select"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
type="submit" type="submit"
> >
{isLoading ? ( {isLoading ? (

View File

@ -4,6 +4,7 @@ import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { TbPolygon, TbPolygonOff } from "react-icons/tb"; import { TbPolygon, TbPolygonOff } from "react-icons/tb";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type PolygonEditControlsProps = { type PolygonEditControlsProps = {
polygons: Polygon[]; polygons: Polygon[];
@ -20,6 +21,7 @@ export default function PolygonEditControls({
snapPoints, snapPoints,
setSnapPoints, setSnapPoints,
}: PolygonEditControlsProps) { }: PolygonEditControlsProps) {
const { t } = useTranslation(["views/settings"]);
const undo = () => { const undo = () => {
if (activePolygonIndex === undefined || !polygons) { if (activePolygonIndex === undefined || !polygons) {
return; return;
@ -80,35 +82,37 @@ export default function PolygonEditControls({
<Button <Button
variant="default" variant="default"
className="size-6 rounded-md p-1" className="size-6 rounded-md p-1"
aria-label="Remove last point" aria-label={t("masksAndZones.form.polygonDrawing.removeLastPoint")}
disabled={!polygons[activePolygonIndex].points.length} disabled={!polygons[activePolygonIndex].points.length}
onClick={undo} onClick={undo}
> >
<MdUndo className="text-secondary-foreground" /> <MdUndo className="text-secondary-foreground" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Remove last point</TooltipContent> <TooltipContent>
{t("masksAndZones.form.polygonDrawing.removeLastPoint")}
</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="default" variant="default"
className="size-6 rounded-md p-1" className="size-6 rounded-md p-1"
aria-label="Clear all points" aria-label={t("masksAndZones.form.polygonDrawing.reset.label")}
disabled={!polygons[activePolygonIndex].points.length} disabled={!polygons[activePolygonIndex].points.length}
onClick={reset} onClick={reset}
> >
<MdOutlineRestartAlt className="text-secondary-foreground" /> <MdOutlineRestartAlt className="text-secondary-foreground" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Reset</TooltipContent> <TooltipContent>{t("button.reset", { ns: "common" })}</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant={snapPoints ? "select" : "default"} variant={snapPoints ? "select" : "default"}
className={cn("size-6 rounded-md p-1")} className={cn("size-6 rounded-md p-1")}
aria-label="Snap points" aria-label={t("masksAndZones.form.polygonDrawing.snapPoints.true")}
onClick={() => setSnapPoints((prev) => !prev)} onClick={() => setSnapPoints((prev) => !prev)}
> >
{snapPoints ? ( {snapPoints ? (
@ -119,7 +123,9 @@ export default function PolygonEditControls({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{snapPoints ? "Don't snap points" : "Snap points"} {snapPoints
? t("masksAndZones.form.polygonDrawing.snapPoints.false")
: t("masksAndZones.form.polygonDrawing.snapPoints.true")}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>

View File

@ -36,7 +36,7 @@ import { reviewQueries } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper"; import IconWrapper from "../ui/icon-wrapper";
import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { buttonVariants } from "../ui/button"; import { buttonVariants } from "../ui/button";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
type PolygonItemProps = { type PolygonItemProps = {
polygon: Polygon; polygon: Polygon;
@ -177,14 +177,25 @@ export default function PolygonItem({
.put(`config/set?${url}`, { requires_restart: 0 }) .put(`config/set?${url}`, { requires_restart: 0 })
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success(`${polygon?.name} has been deleted.`, { toast.success(
position: "top-center", t("masksAndZones.form.polygonDrawing.delete.success", {
}); name: polygon?.name,
}),
{
position: "top-center",
},
);
updateConfig(); updateConfig();
} else { } else {
toast.error(`Failed to save config changes: ${res.statusText}`, { toast.error(
position: "top-center", t("toast.save.error", {
}); errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
} }
}) })
.catch((error) => { .catch((error) => {
@ -192,7 +203,7 @@ export default function PolygonItem({
error.response?.data?.message || error.response?.data?.message ||
error.response?.data?.detail || error.response?.data?.detail ||
"Unknown error"; "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, { toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center", position: "top-center",
}); });
}) })
@ -200,7 +211,7 @@ export default function PolygonItem({
setIsLoading(false); setIsLoading(false);
}); });
}, },
[updateConfig, cameraConfig], [updateConfig, cameraConfig, t],
); );
const handleDelete = () => { const handleDelete = () => {
@ -255,19 +266,30 @@ export default function PolygonItem({
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle> <AlertDialogTitle>
{t("masksAndZones.form.polygonDrawing.delete.title")}
</AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete the{" "} <Trans
{polygon.type.replace("_", " ")} <em>{polygon.name}</em>? ns="views/settings"
values={{
type: polygon.type.replace("_", " "),
name: polygon.name,
}}
>
masksAndZones.form.polygonDrawing.delete.desc
</Trans>
</AlertDialogDescription> </AlertDialogDescription>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className={buttonVariants({ variant: "destructive" })} className={buttonVariants({ variant: "destructive" })}
onClick={handleDelete} onClick={handleDelete}
> >
Delete {t("button.delete", { ns: "common" })}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -281,26 +303,26 @@ export default function PolygonItem({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
aria-label="Edit" aria-label={t("button.edit", { ns: "common" })}
onClick={() => { onClick={() => {
setActivePolygonIndex(index); setActivePolygonIndex(index);
setEditPane(polygon.type); setEditPane(polygon.type);
}} }}
> >
Edit {t("button.edit", { ns: "common" })}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
aria-label="Copy" aria-label={t("button.copy", { ns: "common" })}
onClick={() => handleCopyCoordinates(index)} onClick={() => handleCopyCoordinates(index)}
> >
Copy {t("button.copy", { ns: "common" })}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
aria-label="Delete" aria-label={t("button.delete", { ns: "common" })}
disabled={isLoading} disabled={isLoading}
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}
> >
Delete {t("button.delete", { ns: "common" })}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -48,7 +48,7 @@ export default function ExploreSettings({
const trigger = ( const trigger = (
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Explore Settings" aria-label={t("explore.settings.title")}
size="sm" size="sm"
> >
<FaCog className="text-secondary-foreground" /> <FaCog className="text-secondary-foreground" />

View File

@ -813,7 +813,7 @@ export default function ZoneEditPane({
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Cancel" aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
> >
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
@ -822,7 +822,7 @@ export default function ZoneEditPane({
variant="select" variant="select"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
type="submit" type="submit"
> >
{isLoading ? ( {isLoading ? (

View File

@ -420,7 +420,7 @@ export function DateRangePicker({
<div className="mx-auto flex w-64 items-center justify-evenly gap-2 py-2"> <div className="mx-auto flex w-64 items-center justify-evenly gap-2 py-2">
<Button <Button
variant="select" variant="select"
aria-label="Apply" aria-label={t("button.apply", { ns: "common" })}
onClick={() => { onClick={() => {
setIsOpen(false); setIsOpen(false);
if ( if (
@ -440,7 +440,7 @@ export function DateRangePicker({
onReset?.(); onReset?.();
}} }}
variant="ghost" variant="ghost"
aria-label="Reset" aria-label={t("button.reset", { ns: "common" })}
> >
{t("button.reset", { ns: "common"})} {t("button.reset", { ns: "common"})}
</Button> </Button>

View File

@ -6,6 +6,7 @@ import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
type CarouselApi = UseEmblaCarouselType[1]; type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
@ -196,6 +197,7 @@ const CarouselPrevious = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<typeof Button> React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => { >(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { t } = useTranslation(["views/explore"]);
const { orientation, scrollPrev, canScrollPrev } = useCarousel(); const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return ( return (
@ -210,13 +212,13 @@ const CarouselPrevious = React.forwardRef<
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className, className,
)} )}
aria-label="Previous slide" aria-label={t("objectLifecycle.carousel.previous")}
disabled={!canScrollPrev} disabled={!canScrollPrev}
onClick={scrollPrev} onClick={scrollPrev}
{...props} {...props}
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span> <span className="sr-only">{t("objectLifecycle.carousel.previous")}</span>
</Button> </Button>
); );
}); });
@ -226,6 +228,7 @@ const CarouselNext = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<typeof Button> React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => { >(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { t } = useTranslation(["views/explore"]);
const { orientation, scrollNext, canScrollNext } = useCarousel(); const { orientation, scrollNext, canScrollNext } = useCarousel();
return ( return (
@ -240,13 +243,13 @@ const CarouselNext = React.forwardRef<
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className, className,
)} )}
aria-label="Next slide" aria-label={t("objectLifecycle.carousel.next")}
disabled={!canScrollNext} disabled={!canScrollNext}
onClick={scrollNext} onClick={scrollNext}
{...props} {...props}
> >
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span> <span className="sr-only">{t("objectLifecycle.carousel.next")}</span>
</Button> </Button>
); );
}); });

View File

@ -3,11 +3,12 @@ import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button" import { ButtonProps, buttonVariants } from "@/components/ui/button"
import { t } from "i18next"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav <nav
role="navigation" role="navigation"
aria-label="pagination" aria-label={t("pagination.label")}
className={cn("mx-auto flex w-full justify-center", className)} className={cn("mx-auto flex w-full justify-center", className)}
{...props} {...props}
/> />
@ -64,13 +65,13 @@ const PaginationPrevious = ({
...props ...props
}: React.ComponentProps<typeof PaginationLink>) => ( }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink <PaginationLink
aria-label="Go to previous page" aria-label={t("pagination.previous.label")}
size="default" size="default"
className={cn("gap-1 pl-2.5", className)} className={cn("gap-1 pl-2.5", className)}
{...props} {...props}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
<span>Previous</span> <span>{t("pagination.previous")}</span>
</PaginationLink> </PaginationLink>
) )
PaginationPrevious.displayName = "PaginationPrevious" PaginationPrevious.displayName = "PaginationPrevious"
@ -80,12 +81,12 @@ const PaginationNext = ({
...props ...props
}: React.ComponentProps<typeof PaginationLink>) => ( }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink <PaginationLink
aria-label="Go to next page" aria-label={t("pagination.next.label")}
size="default" size="default"
className={cn("gap-1 pr-2.5", className)} className={cn("gap-1 pr-2.5", className)}
{...props} {...props}
> >
<span>Next</span> <span>{t("pagination.next")}</span>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</PaginationLink> </PaginationLink>
) )
@ -101,7 +102,7 @@ const PaginationEllipsis = ({
{...props} {...props}
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span> <span className="sr-only">{t("pagination.more")}</span>
</span> </span>
) )
PaginationEllipsis.displayName = "PaginationEllipsis" PaginationEllipsis.displayName = "PaginationEllipsis"

View File

@ -198,7 +198,7 @@ function ConfigEditor() {
<Button <Button
size="sm" size="sm"
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Copy config" aria-label={t("copyConfig")}
onClick={() => handleCopyConfig()} onClick={() => handleCopyConfig()}
> >
<LuCopy className="text-secondary-foreground" /> <LuCopy className="text-secondary-foreground" />
@ -207,7 +207,7 @@ function ConfigEditor() {
<Button <Button
size="sm" size="sm"
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Save and restart" aria-label={t("saveAndRestart")}
onClick={() => setRestartDialogOpen(true)} onClick={() => setRestartDialogOpen(true)}
> >
<div className="relative size-5"> <div className="relative size-5">
@ -219,7 +219,7 @@ function ConfigEditor() {
<Button <Button
size="sm" size="sm"
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Save only without restarting" aria-label={t("saveOnly")}
onClick={() => onHandleSaveConfig("saveonly")} onClick={() => onHandleSaveConfig("saveonly")}
> >
<LuSave className="text-secondary-foreground" /> <LuSave className="text-secondary-foreground" />

View File

@ -34,8 +34,10 @@ import { debounce } from "lodash";
import { isIOS, isMobile } from "react-device-detect"; import { isIOS, isMobile } from "react-device-detect";
import { isPWA } from "@/utils/isPWA"; import { isPWA } from "@/utils/isPWA";
import { isInIframe } from "@/utils/isIFrame"; import { isInIframe } from "@/utils/isIFrame";
import { useTranslation } from "react-i18next";
function Logs() { function Logs() {
const { t } = useTranslation(["views/system"]);
const [logService, setLogService] = useState<LogType>("frigate"); const [logService, setLogService] = useState<LogType>("frigate");
const tabsRef = useRef<HTMLDivElement | null>(null); const tabsRef = useRef<HTMLDivElement | null>(null);
const lazyLogWrapperRef = useRef<HTMLDivElement>(null); const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
@ -285,13 +287,13 @@ function Logs() {
fetchInitialLogs() fetchInitialLogs()
.then(() => { .then(() => {
copy(logs.join("\n")); copy(logs.join("\n"));
toast.success("Copied logs to clipboard"); toast.success(t("logs.copy.success"));
}) })
.catch(() => { .catch(() => {
toast.error("Could not copy logs to clipboard"); toast.error(t("logs.copy.error"));
}); });
} }
}, [logs, fetchInitialLogs]); }, [logs, fetchInitialLogs, t]);
const handleDownloadLogs = useCallback(() => { const handleDownloadLogs = useCallback(() => {
axios axios
@ -496,23 +498,25 @@ function Logs() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
className="flex items-center justify-between gap-2" className="flex items-center justify-between gap-2"
aria-label="Copy logs to clipboard" aria-label={t("logs.copy.label")}
size="sm" size="sm"
onClick={handleCopyLogs} onClick={handleCopyLogs}
> >
<FaCopy className="text-secondary-foreground" /> <FaCopy className="text-secondary-foreground" />
<div className="hidden text-primary md:block"> <div className="hidden text-primary md:block">
Copy to Clipboard {t("logs.copy.label")}
</div> </div>
</Button> </Button>
<Button <Button
className="flex items-center justify-between gap-2" className="flex items-center justify-between gap-2"
aria-label="Download logs" aria-label={t("logs.download.label")}
size="sm" size="sm"
onClick={handleDownloadLogs} onClick={handleDownloadLogs}
> >
<FaDownload className="text-secondary-foreground" /> <FaDownload className="text-secondary-foreground" />
<div className="hidden text-primary md:block">Download</div> <div className="hidden text-primary md:block">
{t("button.download", { ns: "common" })}
</div>
</Button> </Button>
<LogSettingsButton <LogSettingsButton
selectedLabels={filterSeverity} selectedLabels={filterSeverity}
@ -527,8 +531,10 @@ function Logs() {
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12"> <div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
<div className="col-span-3 lg:col-span-2"> <div className="col-span-3 lg:col-span-2">
<div className="flex w-full flex-row items-center"> <div className="flex w-full flex-row items-center">
<div className="ml-1 min-w-16 capitalize lg:min-w-20">Type</div> <div className="ml-1 min-w-16 capitalize lg:min-w-20">
<div className="mr-3">Timestamp</div> {t("logs.type.label")}
</div>
<div className="mr-3">{t("logs.type.timestamp")}</div>
</div> </div>
</div> </div>
<div <div
@ -537,7 +543,7 @@ function Logs() {
logService == "frigate" ? "col-span-2" : "col-span-1", logService == "frigate" ? "col-span-2" : "col-span-1",
)} )}
> >
Tag {t("logs.type.tag")}
</div> </div>
<div <div
className={cn( className={cn(
@ -547,7 +553,7 @@ function Logs() {
: "md:col-span-8 lg:col-span-9", : "md:col-span-8 lg:col-span-9",
)} )}
> >
<div className="flex flex-1">Message</div> <div className="flex flex-1">{t("logs.type.message")}</div>
</div> </div>
</div> </div>
@ -566,9 +572,7 @@ function Logs() {
<TooltipTrigger> <TooltipTrigger>
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" /> <MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>{t("logs.tips")}</TooltipContent>
Logs are streaming from the server
</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
)} )}

View File

@ -1,8 +1,10 @@
import { ObjectLifecycleSequence } from "@/types/timeline"; import { ObjectLifecycleSequence } from "@/types/timeline";
import { t } from "i18next";
export function getLifecycleItemDescription( export function getLifecycleItemDescription(
lifecycleItem: ObjectLifecycleSequence, lifecycleItem: ObjectLifecycleSequence,
) { ) {
// can't use useTranslation here
const label = ( const label = (
(Array.isArray(lifecycleItem.data.sub_label) (Array.isArray(lifecycleItem.data.sub_label)
? lifecycleItem.data.sub_label[0] ? lifecycleItem.data.sub_label[0]
@ -11,37 +13,63 @@ export function getLifecycleItemDescription(
switch (lifecycleItem.class_type) { switch (lifecycleItem.class_type) {
case "visible": case "visible":
return `${label} detected`; return t("objectLifecycle.lifecycleItemDesc.visible", {
label,
ns: "views/explore",
});
case "entered_zone": case "entered_zone":
return `${label} entered ${lifecycleItem.data.zones return t("objectLifecycle.lifecycleItemDesc.entered_zone", {
.join(" and ") label,
.replaceAll("_", " ")}`; ns: "views/explore",
zones: lifecycleItem.data.zones.join(" and ").replaceAll("_", " "),
});
case "active": case "active":
return `${label} became active`; return t("objectLifecycle.lifecycleItemDesc.active", {
label,
ns: "views/explore",
});
case "stationary": case "stationary":
return `${label} became stationary`; return t("objectLifecycle.lifecycleItemDesc.stationary", {
label,
ns: "views/explore",
});
case "attribute": { case "attribute": {
let title = ""; let title = "";
if ( if (
lifecycleItem.data.attribute == "face" || lifecycleItem.data.attribute == "face" ||
lifecycleItem.data.attribute == "license_plate" lifecycleItem.data.attribute == "license_plate"
) { ) {
title = `${lifecycleItem.data.attribute.replaceAll( title = t(
"_", "objectLifecycle.lifecycleItemDesc.attribute.faceOrLicense_plate",
" ", {
)} detected for ${label}`; label,
attribute: lifecycleItem.data.attribute.replaceAll("_", " "),
ns: "views/explore",
},
);
} else { } else {
title = `${ title = t("objectLifecycle.lifecycleItemDesc.attribute.other", {
lifecycleItem.data.label label: lifecycleItem.data.label,
} recognized as ${lifecycleItem.data.attribute.replaceAll("_", " ")}`; attribute: lifecycleItem.data.attribute.replaceAll("_", " "),
ns: "views/explore",
});
} }
return title; return title;
} }
case "gone": case "gone":
return `${label} left`; return t("objectLifecycle.lifecycleItemDesc.gone", {
label,
ns: "views/explore",
});
case "heard": case "heard":
return `${label} heard`; return t("objectLifecycle.lifecycleItemDesc.heard", {
label,
ns: "views/explore",
});
case "external": case "external":
return `${label} detected`; return t("objectLifecycle.lifecycleItemDesc.external", {
label,
ns: "views/explore",
});
} }
} }

View File

@ -277,7 +277,7 @@ export default function EventView({
<ToggleGroupItem <ToggleGroupItem
className={cn(severityToggle != "alert" && "text-muted-foreground")} className={cn(severityToggle != "alert" && "text-muted-foreground")}
value="alert" value="alert"
aria-label="Select alerts" aria-label={t("alerts")}
> >
{isMobileOnly ? ( {isMobileOnly ? (
<div <div
@ -311,7 +311,7 @@ export default function EventView({
severityToggle != "detection" && "text-muted-foreground", severityToggle != "detection" && "text-muted-foreground",
)} )}
value="detection" value="detection"
aria-label="Select detections" aria-label={t("detections")}
> >
{isMobileOnly ? ( {isMobileOnly ? (
<div <div
@ -348,7 +348,7 @@ export default function EventView({
severityToggle != "significant_motion" && "text-muted-foreground", severityToggle != "significant_motion" && "text-muted-foreground",
)} )}
value="significant_motion" value="significant_motion"
aria-label="Select motion" aria-label={t("motion.label")}
> >
{isMobileOnly ? ( {isMobileOnly ? (
<GiSoundWaves className="size-6 rotate-90 text-severity_significant_motion" /> <GiSoundWaves className="size-6 rotate-90 text-severity_significant_motion" />
@ -792,14 +792,14 @@ function DetectionReview({
<div className="col-span-full flex items-center justify-center"> <div className="col-span-full flex items-center justify-center">
<Button <Button
className="text-white" className="text-white"
aria-label="Mark these items as reviewed" aria-label={t("markTheseItemsAsReviewed")}
variant="select" variant="select"
onClick={() => { onClick={() => {
setSelectedReviews([]); setSelectedReviews([]);
markAllItemsAsReviewed(currentItems ?? []); markAllItemsAsReviewed(currentItems ?? []);
}} }}
> >
Mark these items as reviewed {t("markTheseItemsAsReviewed")}
</Button> </Button>
</div> </div>
)} )}

View File

@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { t } from "i18next";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
isDesktop, isDesktop,
@ -144,12 +145,14 @@ export default function LiveBirdseyeView({
{!fullscreen ? ( {!fullscreen ? (
<Button <Button
className={`flex items-center gap-2 rounded-lg ${isMobile ? "ml-2" : "ml-0"}`} className={`flex items-center gap-2 rounded-lg ${isMobile ? "ml-2" : "ml-0"}`}
aria-label="Go Back" aria-label={t("label.back", { ns: "common" })}
size={isMobile ? "icon" : "sm"} size={isMobile ? "icon" : "sm"}
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
<IoMdArrowBack className="size-5" /> <IoMdArrowBack className="size-5" />
{isDesktop && <div className="text-primary">Back</div>} {isDesktop && (
<div className="text-primary">{t("button.back")}</div>
)}
</Button> </Button>
) : ( ) : (
<div /> <div />

View File

@ -118,7 +118,7 @@ import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
type LiveCameraViewProps = { type LiveCameraViewProps = {
config?: FrigateConfig; config?: FrigateConfig;
@ -430,7 +430,7 @@ export default function LiveCameraView({
> >
<Button <Button
className={`flex items-center gap-2.5 rounded-lg`} className={`flex items-center gap-2.5 rounded-lg`}
aria-label="Go back" aria-label={t("label.back", { ns: "common" })}
size="sm" size="sm"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
@ -443,7 +443,7 @@ export default function LiveCameraView({
</Button> </Button>
<Button <Button
className="flex items-center gap-2.5 rounded-lg" className="flex items-center gap-2.5 rounded-lg"
aria-label="Show historical footage" aria-label={t("history.label")}
size="sm" size="sm"
onClick={() => { onClick={() => {
navigate("review", { navigate("review", {
@ -476,7 +476,7 @@ export default function LiveCameraView({
{fullscreen && ( {fullscreen && (
<Button <Button
className="bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-primary" className="bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-primary"
aria-label="Go back" aria-label={t("label.back", { ns: "common" })}
size="sm" size="sm"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
@ -876,14 +876,19 @@ function PtzControlPanel({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
className={`${clickOverlay ? "text-selected" : "text-primary"}`} className={`${clickOverlay ? "text-selected" : "text-primary"}`}
aria-label="Click in the frame to center the camera" aria-label={t("ptz.move.clickMove.label")}
onClick={() => setClickOverlay(!clickOverlay)} onClick={() => setClickOverlay(!clickOverlay)}
> >
<TbViewfinder /> <TbViewfinder />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{clickOverlay ? "Disable" : "Enable"} click to move</p> <p>
{clickOverlay
? t("ptz.move.clickMove.disable")
: t("ptz.move.clickMove.enable")}{" "}
click to move
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@ -894,7 +899,7 @@ function PtzControlPanel({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<DropdownMenu modal={!isDesktop}> <DropdownMenu modal={!isDesktop}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button aria-label="PTZ camera presets"> <Button aria-label={t("ptz.presets")}>
<BsThreeDotsVertical /> <BsThreeDotsVertical />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -916,7 +921,7 @@ function PtzControlPanel({
</DropdownMenu> </DropdownMenu>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>PTZ camera presets</p> <p>{t("ptz.presets")}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@ -926,6 +931,7 @@ function PtzControlPanel({
} }
function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) { function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
const { t } = useTranslation(["views/live"]);
const rankMap = { all: 0, motion: 1, active_objects: 2 }; const rankMap = { all: 0, motion: 1, active_objects: 2 };
const getValidMode = (retain?: { mode?: string }): keyof typeof rankMap => { const getValidMode = (retain?: { mode?: string }): keyof typeof rankMap => {
const mode = retain?.mode; const mode = retain?.mode;
@ -940,13 +946,25 @@ function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
? recordRetainMode ? recordRetainMode
: alertsRetainMode; : alertsRetainMode;
const source = effectiveRetainMode === recordRetainMode ? "camera" : "alerts"; const source =
effectiveRetainMode === recordRetainMode
? t("camera", { ns: "views/events" })
: t("alerts", { ns: "views/events" });
return effectiveRetainMode !== "all" ? ( return effectiveRetainMode !== "all" ? (
<div> <div>
Your {source} recording retention configuration is set to{" "} <Trans
<code>mode: {effectiveRetainMode}</code>, so this on-demand recording will ns="views/live"
only keep segments with {effectiveRetainMode.replaceAll("_", " ")}. values={{
source,
effectiveRetainMode,
effectiveRetainModeName: t(
"effectiveRetainMode.modes." + effectiveRetainMode,
),
}}
>
effectiveRetainMode.notAllTips
</Trans>
</div> </div>
) : null; ) : null;
} }
@ -1034,9 +1052,7 @@ function FrigateCameraFeatures({
setIsRecording(true); setIsRecording(true);
const toastId = toast.success( const toastId = toast.success(
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
<div className="font-semibold"> <div className="font-semibold">{t("manualRecording.started")}</div>
Started manual on-demand recording.
</div>
{!camera.record.enabled || camera.record.alerts.retain.days == 0 ? ( {!camera.record.enabled || camera.record.alerts.retain.days == 0 ? (
<div>{t("manualRecording.recordDisabledTips")}</div> <div>{t("manualRecording.recordDisabledTips")}</div>
) : ( ) : (

View File

@ -395,7 +395,7 @@ export function RecordingView({
<div className={cn("flex items-center gap-2")}> <div className={cn("flex items-center gap-2")}>
<Button <Button
className="flex items-center gap-2.5 rounded-lg" className="flex items-center gap-2.5 rounded-lg"
aria-label="Go back" aria-label={t("label.back", { ns: "common" })}
size="sm" size="sm"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
@ -492,14 +492,14 @@ export function RecordingView({
<ToggleGroupItem <ToggleGroupItem
className={`${timelineType == "timeline" ? "" : "text-muted-foreground"}`} className={`${timelineType == "timeline" ? "" : "text-muted-foreground"}`}
value="timeline" value="timeline"
aria-label="Select timeline" aria-label={t("timeline.aria")}
> >
<div className="">{t("timeline")}</div> <div className="">{t("timeline")}</div>
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem <ToggleGroupItem
className={`${timelineType == "events" ? "" : "text-muted-foreground"}`} className={`${timelineType == "events" ? "" : "text-muted-foreground"}`}
value="events" value="events"
aria-label="Select events" aria-label={t("events.aria")}
> >
<div className="">{t("events.label")}</div> <div className="">{t("events.label")}</div>
</ToggleGroupItem> </ToggleGroupItem>

View File

@ -202,7 +202,7 @@ export default function AuthenticationView() {
</div> </div>
<Button <Button
className="flex items-center gap-2 self-start sm:self-auto" className="flex items-center gap-2 self-start sm:self-auto"
aria-label="Add a new user" aria-label={t("users.addUser")}
variant="default" variant="default"
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}
> >

View File

@ -633,7 +633,7 @@ export default function CameraSettingsView({
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]"> <div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Cancel" aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
type="button" type="button"
> >
@ -643,7 +643,7 @@ export default function CameraSettingsView({
variant="select" variant="select"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
type="submit" type="submit"
> >
{isLoading ? ( {isLoading ? (

View File

@ -518,7 +518,7 @@ export default function MasksAndZonesView({
<Button <Button
variant="secondary" variant="secondary"
className="size-6 rounded-md bg-secondary-foreground p-1 text-background" className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
aria-label="Add a new zone" aria-label={t("masksAndZones.zones.add")}
onClick={() => { onClick={() => {
setEditPane("zone"); setEditPane("zone");
handleNewPolygon("zone"); handleNewPolygon("zone");
@ -586,7 +586,7 @@ export default function MasksAndZonesView({
<Button <Button
variant="secondary" variant="secondary"
className="size-6 rounded-md bg-secondary-foreground p-1 text-background" className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
aria-label="Add a new motion mask" aria-label={t("masksAndZones.motionMasks.add")}
onClick={() => { onClick={() => {
setEditPane("motion_mask"); setEditPane("motion_mask");
handleNewPolygon("motion_mask"); handleNewPolygon("motion_mask");
@ -654,7 +654,7 @@ export default function MasksAndZonesView({
<Button <Button
variant="secondary" variant="secondary"
className="size-6 rounded-md bg-secondary-foreground p-1 text-background" className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
aria-label="Add a new object mask" aria-label={t("masksAndZones.objectMasks.add")}
onClick={() => { onClick={() => {
setEditPane("object_mask"); setEditPane("object_mask");
handleNewPolygon("object_mask"); handleNewPolygon("object_mask");

View File

@ -295,7 +295,7 @@ export default function MotionTunerView({
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 pt-5">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Reset" aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
> >
{t("button.reset", { ns: "common" })} {t("button.reset", { ns: "common" })}
@ -304,7 +304,7 @@ export default function MotionTunerView({
variant="select" variant="select"
disabled={!changedValue || isLoading} disabled={!changedValue || isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
onClick={saveToConfig} onClick={saveToConfig}
> >
{isLoading ? ( {isLoading ? (

View File

@ -266,9 +266,15 @@ export default function NotificationView({
}); });
updateConfig(); updateConfig();
} else { } else {
toast.error(`Failed to save config changes: ${res.statusText}`, { toast.error(
position: "top-center", t("toast.save.error", {
}); errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
} }
}) })
.catch((error) => { .catch((error) => {
@ -276,7 +282,7 @@ export default function NotificationView({
error.response?.data?.message || error.response?.data?.message ||
error.response?.data?.detail || error.response?.data?.detail ||
"Unknown error"; "Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, { toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center", position: "top-center",
}); });
}) })
@ -284,7 +290,7 @@ export default function NotificationView({
setIsLoading(false); setIsLoading(false);
}); });
}, },
[updateConfig, setIsLoading, allCameras], [updateConfig, setIsLoading, allCameras, t],
); );
function onSubmit(values: z.infer<typeof formSchema>) { function onSubmit(values: z.infer<typeof formSchema>) {
@ -474,7 +480,7 @@ export default function NotificationView({
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]"> <div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label="Cancel" aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
type="button" type="button"
> >
@ -484,7 +490,7 @@ export default function NotificationView({
variant="select" variant="select"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
type="submit" type="submit"
> >
{isLoading ? ( {isLoading ? (

View File

@ -295,14 +295,18 @@ export default function ExploreSettingsView({
<Separator className="my-2 flex bg-secondary" /> <Separator className="my-2 flex bg-secondary" />
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]"> <div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button className="flex flex-1" aria-label="Reset" onClick={onCancel}> <Button
className="flex flex-1"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
>
{t("button.reset", { ns: "common" })} {t("button.reset", { ns: "common" })}
</Button> </Button>
<Button <Button
variant="select" variant="select"
disabled={!changedValue || isLoading} disabled={!changedValue || isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Save" aria-label={t("button.save", { ns: "common" })}
onClick={saveToConfig} onClick={saveToConfig}
> >
{isLoading ? ( {isLoading ? (

View File

@ -142,7 +142,7 @@ export default function UiSettingsView() {
</div> </div>
</div> </div>
<Button <Button
aria-label="Clear all saved layouts" aria-label={t("general.storedLayouts.clearAll")}
onClick={clearStoredLayouts} onClick={clearStoredLayouts}
> >
{t("general.storedLayouts.clearAll")} {t("general.storedLayouts.clearAll")}
@ -159,7 +159,7 @@ export default function UiSettingsView() {
</div> </div>
</div> </div>
<Button <Button
aria-label="Clear all group streaming settings" aria-label={t("general.cameraGroupStreaming.clearAll")}
onClick={clearStreamingSettings} onClick={clearStreamingSettings}
> >
{t("general.cameraGroupStreaming.clearAll")} {t("general.cameraGroupStreaming.clearAll")}

View File

@ -542,7 +542,7 @@ export default function GeneralMetrics({
{canGetGpuInfo && ( {canGetGpuInfo && (
<Button <Button
className="cursor-pointer" className="cursor-pointer"
aria-label="Hardware information" aria-label={t("general.hardwareInfo.title")}
size="sm" size="sm"
onClick={() => setShowVainfo(true)} onClick={() => setShowVainfo(true)}
> >

View File

@ -87,11 +87,15 @@ export default function StorageMetrics({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
className="focus:outline-none" className="focus:outline-none"
aria-label="Unused Storage Information" aria-label={t(
"storage.cameraStorage.unusedStorageInformation",
)}
> >
<CiCircleAlert <CiCircleAlert
className="size-5" className="size-5"
aria-label="Unused Storage Information" aria-label={t(
"storage.cameraStorage.unusedStorageInformation",
)}
/> />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
@ -107,7 +111,9 @@ export default function StorageMetrics({
/> />
{earliestDate && ( {earliestDate && (
<div className="mt-2 text-xs text-primary-variant"> <div className="mt-2 text-xs text-primary-variant">
<span className="font-medium">Earliest recording available:</span>{" "} <span className="font-medium">
{t("storage.recordings.earliestRecording")}
</span>{" "}
{formatUnixTimestampToDateTime(earliestDate, { {formatUnixTimestampToDateTime(earliestDate, {
timezone: timezone, timezone: timezone,
strftime_fmt: strftime_fmt: