From fef927a5e9c501b70d066fc1c57617d71fc0b53c Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Mon, 19 Oct 2026 06:59:17 -0400 Subject: [PATCH 1/2] i18n: localize remaining UI strings and guard JSX literals --- web/eslint-rules/no-literal-ui-string.js | 64 ++++++++ web/eslint.config.js | 8 + web/i18n-literal-baseline.json | 124 +++++++++++++++ web/js/components/preact/AsyncButton.jsx | 4 +- web/js/components/preact/BatchDeleteModal.jsx | 52 +++--- .../components/preact/BatchDownloadModal.jsx | 34 ++-- web/js/components/preact/DetectionOverlay.jsx | 5 +- .../components/preact/FisheyeEptzCanvas.jsx | 2 +- .../preact/ManualRecordingButton.jsx | 6 +- web/js/components/preact/RecordingsView.jsx | 2 +- web/js/components/preact/SettingsView.jsx | 2 +- .../components/preact/StreamConfigModal.jsx | 28 ++-- web/js/components/preact/StreamsView.jsx | 2 +- web/js/components/preact/UI.jsx | 123 ++++++++------- web/js/components/preact/WebRTCVideoCell.jsx | 2 +- web/js/components/preact/WebRTCView.jsx | 2 +- .../preact/live/LiveOperatorNavigator.jsx | 2 +- .../preact/recordings/RecordingsGrid.jsx | 6 +- .../preact/recordings/RecordingsTable.jsx | 5 +- .../preact/recordings/TagsOverlay.jsx | 18 +-- .../preact/recordings/recordingsAPI.jsx | 35 +++-- web/js/components/preact/settings/AuthTab.jsx | 2 +- .../components/preact/settings/Go2rtcTab.jsx | 32 ++-- .../components/preact/settings/StorageTab.jsx | 2 +- .../preact/settings/StreamsDefaultsTab.jsx | 4 +- .../components/preact/system/NetworkInfo.jsx | 2 +- .../preact/system/StreamStorage.jsx | 2 +- .../preact/timeline/CalendarPicker.jsx | 27 ++-- .../preact/timeline/TimelinePage.jsx | 14 +- .../preact/timeline/TimelinePlayer.jsx | 2 +- .../preact/timeline/TimelineSegments.jsx | 4 +- web/js/utils/batch-delete-status.js | 10 +- web/public/locales/en.json | 148 +++++++++++++++++- web/tests/locale-coverage.spec.js | 31 ++++ web/tests/recordingsAPI.spec.js | 7 + 35 files changed, 602 insertions(+), 211 deletions(-) create mode 100644 web/eslint-rules/no-literal-ui-string.js create mode 100644 web/i18n-literal-baseline.json diff --git a/web/eslint-rules/no-literal-ui-string.js b/web/eslint-rules/no-literal-ui-string.js new file mode 100644 index 000000000..3ca92ce6e --- /dev/null +++ b/web/eslint-rules/no-literal-ui-string.js @@ -0,0 +1,64 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const baseline = JSON.parse(fs.readFileSync(new URL('../i18n-literal-baseline.json', import.meta.url), 'utf8')); +const componentsRoot = fileURLToPath(new URL('../js/components/', import.meta.url)); +const visibleAttributes = new Set(['title', 'aria-label', 'placeholder', 'alt']); + +function candidate(value) { + const normalized = value.replace(/\s+/g, ' ').trim(); + return /[A-Za-z]/.test(normalized) ? normalized : null; +} + +// Only inspect string branches that become JSX children. Calls such as t('key'), +// class names, API paths, and logging arguments are outside this rule's scope. +function expressionStrings(node) { + if (!node) return []; + if (node.type === 'Literal' && typeof node.value === 'string') return [node.value]; + if (node.type === 'TemplateLiteral') return node.quasis.map((part) => part.value.cooked || ''); + if (node.type === 'ConditionalExpression') { + return [...expressionStrings(node.consequent), ...expressionStrings(node.alternate)]; + } + if (node.type === 'LogicalExpression') return expressionStrings(node.right); + if (node.type === 'BinaryExpression' && node.operator === '+') { + return [...expressionStrings(node.left), ...expressionStrings(node.right)]; + } + return []; +} + +export default { + meta: { + type: 'problem', + docs: { description: 'Disallow new user-visible JSX literals' }, + schema: [], + }, + create(context) { + const file = path.relative(componentsRoot, context.filename).replaceAll(path.sep, '/'); + const allowed = baseline[file] || {}; + const seen = new Map(); + + function check(node, text) { + const value = candidate(text); + if (!value) return; + const count = (seen.get(value) || 0) + 1; + seen.set(value, count); + if (process.env.I18N_LITERAL_BASELINE === '1' || count > (allowed[value] || 0)) { + context.report({ node, message: `Untranslated UI string: ${JSON.stringify(value)}` }); + } + } + + return { + JSXText(node) { check(node, node.value); }, + JSXAttribute(node) { + if (visibleAttributes.has(node.name.name) && node.value?.type === 'Literal') { + check(node, node.value.value); + } + }, + JSXExpressionContainer(node) { + if (node.parent.type === 'JSXAttribute') return; + for (const value of expressionStrings(node.expression)) check(node, value); + }, + }; + }, +}; diff --git a/web/eslint.config.js b/web/eslint.config.js index e9479db0d..8696d77c0 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -8,6 +8,7 @@ */ import jsxA11y from 'eslint-plugin-jsx-a11y'; import reactHooks from 'eslint-plugin-react-hooks'; +import noLiteralUiString from './eslint-rules/no-literal-ui-string.js'; const recommendedRules = jsxA11y.flatConfigs.recommended.rules; @@ -45,4 +46,11 @@ export default [ 'react-hooks/rules-of-hooks': 'off', }, }, + { + files: ['js/components/**/*.js', 'js/components/**/*.jsx'], + // Existing technical labels and examples are counted by file in the + // baseline. New JSX text or visible-attribute literals fail lint. + plugins: { 'lightnvr-i18n': { rules: { 'no-literal-ui-string': noLiteralUiString } } }, + rules: { 'lightnvr-i18n/no-literal-ui-string': 'error' }, + }, ]; diff --git a/web/i18n-literal-baseline.json b/web/i18n-literal-baseline.json new file mode 100644 index 000000000..ad985a13b --- /dev/null +++ b/web/i18n-literal-baseline.json @@ -0,0 +1,124 @@ +{ + "preact/BatchDownloadModal.jsx": { + "recordings.zip": 1 + }, + "preact/EventRoutingView.jsx": { + "· QoS": 1 + }, + "preact/FisheyeEptzCanvas.jsx": { + "ePTZ": 1 + }, + "preact/Footer.jsx": { + "LightNVR ©": 1 + }, + "preact/HLSVideoCell.jsx": { + "go2rtc": 1, + "HLS": 2 + }, + "preact/Header.jsx": { + "LightNVR": 1, + "v": 1 + }, + "preact/HealthView.jsx": { + "ms": 1 + }, + "preact/LoginView.jsx": { + "LightNVR": 1 + }, + "preact/MSEVideoCell.jsx": { + "PTZ": 1, + "MSE": 1 + }, + "preact/SetupWizard.jsx": { + "/var/lib/lightnvr/recordings": 1, + "/var/lib/lightnvr/lightnvr.db": 1 + }, + "preact/StreamCard.jsx": { + "ONVIF": 2 + }, + "preact/StreamConfigModal.jsx": { + "http://192.168.1.100/": 1, + "http://": 1, + "https://": 1, + "outdoor,critical": 1, + "admin": 1, + "Profile_1": 1, + "H.264 (AVC)": 1, + "H.265 (HEVC)": 1, + "TCP": 1, + "UDP": 1, + "[api_detection]": 1, + "rtmp://a.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx": 1 + }, + "preact/StreamsView.jsx": { + "admin": 1, + "password": 1, + "x": 1 + }, + "preact/WebRTCVideoCell.jsx": { + "WebRTC": 1 + }, + "preact/events/EventDestinationEditor.jsx": { + "mqtt.example.net": 1, + "lightnvr/v1/status/{installation_uuid}/{destination_uuid}": 1, + "/etc/lightnvr/certs/broker-ca.pem": 1, + "/etc/lightnvr/certs/client.pem": 1, + "/etc/lightnvr/certs/client.key": 1 + }, + "preact/events/EventRouteEditor.jsx": { + "person, vehicle": 1, + "entrance, loading-dock": 1, + "America/New_York": 1 + }, + "preact/fleet/FleetTable.jsx": { + "FPS": 1 + }, + "preact/fleet/OrganizationManager.jsx": { + "#2563eb": 1 + }, + "preact/settings/AuthTab.jsx": { + "X-Forwarded-For": 1, + "X-Real-IP": 1 + }, + "preact/settings/DetectionTab.jsx": { + "/var/lib/lightnvr/models": 1, + "default": 1 + }, + "preact/settings/GeneralTab.jsx": { + "lightnvr": 1, + "LOG_USER": 1, + "LOG_DAEMON": 1, + "LOG_LOCAL0": 1, + "LOG_LOCAL1": 1, + "LOG_LOCAL2": 1, + "LOG_LOCAL3": 1, + "LOG_LOCAL4": 1, + "LOG_LOCAL5": 1, + "LOG_LOCAL6": 1, + "LOG_LOCAL7": 1 + }, + "preact/settings/Go2rtcTab.jsx": { + "/usr/local/bin/go2rtc": 1, + "/etc/lightnvr/go2rtc": 1, + "stun.l.google.com:19302": 1, + "override.yaml": 1 + }, + "preact/settings/MqttTab.jsx": { + "localhost": 1, + "lightnvr": 2, + "homeassistant": 1 + }, + "preact/settings/StorageTab.jsx": { + ".bak": 1, + "/usr/local/bin/lightnvr-post-backup": 1 + }, + "preact/users/AddUserModal.jsx": { + "(CIDR)": 1 + }, + "preact/users/AuditHistoryModal.jsx": { + "recordings.export": 1 + }, + "preact/users/EditUserModal.jsx": { + "(CIDR)": 1 + } +} diff --git a/web/js/components/preact/AsyncButton.jsx b/web/js/components/preact/AsyncButton.jsx index b00acd75d..79cede5a0 100644 --- a/web/js/components/preact/AsyncButton.jsx +++ b/web/js/components/preact/AsyncButton.jsx @@ -37,6 +37,7 @@ import { Fragment } from 'preact'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useAsyncAction } from '../../hooks/useAsyncAction.js'; +import { useI18n } from '../../i18n.js'; /** * Inline 16x16 spinner icon, rotated via Tailwind's `animate-spin`. @@ -131,6 +132,7 @@ export function AsyncButton({ type = 'button', ...rest }) { + const { t } = useI18n(); const { run, pending, error } = useAsyncAction(onClick || (() => Promise.resolve())); // Track the brief success flash. @@ -241,7 +243,7 @@ export function AsyncButton({ content = ( - Working… + {t('recordings.working')} ); } else if (showSuccess) { diff --git a/web/js/components/preact/BatchDeleteModal.jsx b/web/js/components/preact/BatchDeleteModal.jsx index 6ef05ab80..7afc60c07 100644 --- a/web/js/components/preact/BatchDeleteModal.jsx +++ b/web/js/components/preact/BatchDeleteModal.jsx @@ -7,6 +7,7 @@ import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; import { showStatusMessage } from './ToastContainer.jsx'; import { batchDeleteStatus } from '../../utils/batch-delete-status.js'; import { useQueryClient } from '../../query-client.js'; +import { t as translate, useI18n } from '../../i18n.js'; /** * BatchDeleteModal component @@ -18,6 +19,7 @@ import { useQueryClient } from '../../query-client.js'; * @returns {JSX.Element} BatchDeleteModal component */ export function BatchDeleteModal() { + const { t } = useI18n(); const queryClient = useQueryClient(); // Modal phase: 'hidden' | 'confirm' | 'progress' @@ -47,9 +49,9 @@ export function BatchDeleteModal() { const getConfirmDescription = () => { const params = pendingParamsRef.current; if (!params) return ''; - if (params.ids) return `${params.ids.length} selected recording${params.ids.length !== 1 ? 's' : ''}`; - if (params.totalCount) return `${params.totalCount} filtered recording${params.totalCount !== 1 ? 's' : ''}`; - return 'all matching recordings'; + if (params.ids) return t(params.ids.length === 1 ? 'recordings.selectedRecordingOne' : 'recordings.selectedRecordingMany', { count: params.ids.length }); + if (params.totalCount) return t(params.totalCount === 1 ? 'recordings.filteredRecordingOne' : 'recordings.filteredRecordingMany', { count: params.totalCount }); + return t('recordings.allMatchingRecordings'); }; /** @@ -71,7 +73,7 @@ export function BatchDeleteModal() { */ const cancelConfirm = useCallback(() => { closeModal(); - showStatusMessage('Batch delete cancelled', 'info', 3000); + showStatusMessage(t('recordings.batchDeleteCancelled'), 'info', 3000); }, [closeModal]); /** @@ -231,7 +233,7 @@ export function BatchDeleteModal() { }); // Show error message - showStatusMessage(`Error: ${error.message || 'Unknown error'}`, 'error', 5000); + showStatusMessage(t('recordings.errorMessage', { message: error.message || t('recordings.unknownError') }), 'error', 5000); reject(error); }); @@ -258,7 +260,7 @@ export function BatchDeleteModal() { }); // Show error message - showStatusMessage(`Error: ${error.message || 'Unknown error'}`, 'error', 5000); + showStatusMessage(t('recordings.errorMessage', { message: error.message || t('recordings.unknownError') }), 'error', 5000); reject(error); }); @@ -331,7 +333,7 @@ export function BatchDeleteModal() { } else if (progress.complete) { return '100%'; } else { - return 'In progress'; + return t('recordings.inProgress'); } }; @@ -351,7 +353,7 @@ export function BatchDeleteModal() { {phase === 'confirm' && ( <>
-

Confirm Batch Delete

+

{t('recordings.confirmBatchDelete')}

@@ -379,13 +381,13 @@ export function BatchDeleteModal() { className="px-4 py-2 bg-secondary text-secondary-foreground rounded hover:bg-secondary/80 transition-colors" onClick={cancelConfirm} > - Cancel + {t('common.cancel')} @@ -396,7 +398,7 @@ export function BatchDeleteModal() { <>

- {progress.complete ? 'Batch Delete Complete' : 'Deleting Recordings…'} + {t(progress.complete ? 'recordings.batchDeleteComplete' : 'recordings.deletingRecordings')}

@@ -415,27 +417,27 @@ export function BatchDeleteModal() {
- Succeeded: + {t('recordings.succeededLabel')} {progress.succeeded}
- Failed: + {t('recordings.failedLabel')} {progress.failed}
{!progress.complete && (
- This operation cannot be cancelled once started. + {t('recordings.batchDeleteCannotCancel')}
)}
{progress.complete ? ( - + ) : ( - Please wait… + {t('recordings.pleaseWait')} )}
@@ -453,7 +455,7 @@ export function BatchDeleteModal() { */ export function batchDeleteRecordings(ids) { if (!ids || ids.length === 0) { - showStatusMessage('No recordings selected for deletion', 'warning', 5000); + showStatusMessage(translate('recordings.noSelectionForDeletion'), 'warning', 5000); return; } @@ -461,13 +463,13 @@ export function batchDeleteRecordings(ids) { window.batchDeleteRecordingsByHttpRequest({ ids }) .catch(error => { console.error('Error in batch delete:', error); - showStatusMessage(`Error: ${error.message || 'Failed to start batch delete operation'}`, 'error', 5000); + showStatusMessage(translate('recordings.errorMessage', { message: error.message || translate('recordings.batchDeleteStartFailed') }), 'error', 5000); if (typeof window.closeBatchDeleteModal === 'function') { window.closeBatchDeleteModal(); } }); } else { - showStatusMessage('Batch delete function not available', 'error', 5000); + showStatusMessage(translate('recordings.batchDeleteUnavailable'), 'error', 5000); } } @@ -478,7 +480,7 @@ export function batchDeleteRecordings(ids) { */ export function batchDeleteRecordingsByFilter(filter) { if (!filter) { - showStatusMessage('No filter specified for deletion', 'warning', 5000); + showStatusMessage(translate('recordings.noFilterForDeletion'), 'warning', 5000); return; } @@ -486,13 +488,13 @@ export function batchDeleteRecordingsByFilter(filter) { window.batchDeleteRecordingsByHttpRequest({ filter }) .catch(error => { console.error('Error in batch delete:', error); - showStatusMessage(`Error: ${error.message || 'Failed to start batch delete operation'}`, 'error', 5000); + showStatusMessage(translate('recordings.errorMessage', { message: error.message || translate('recordings.batchDeleteStartFailed') }), 'error', 5000); if (typeof window.closeBatchDeleteModal === 'function') { window.closeBatchDeleteModal(); } }); } else { - showStatusMessage('Batch delete function not available', 'error', 5000); + showStatusMessage(translate('recordings.batchDeleteUnavailable'), 'error', 5000); } } diff --git a/web/js/components/preact/BatchDownloadModal.jsx b/web/js/components/preact/BatchDownloadModal.jsx index 172e21991..74a83645f 100644 --- a/web/js/components/preact/BatchDownloadModal.jsx +++ b/web/js/components/preact/BatchDownloadModal.jsx @@ -3,13 +3,14 @@ */ import { useState, useEffect, useCallback } from 'preact/hooks'; import { showStatusMessage } from './ToastContainer.jsx'; +import { t as translate, useI18n } from '../../i18n.js'; /** * Sequential download: trigger one download per recording with a small delay. * @param {Array} recordings Full recording objects (must have .id, .stream, .start_time) */ async function downloadSequential(recordings) { - showStatusMessage(`Starting ${recordings.length} download${recordings.length !== 1 ? 's' : ''}…`); + showStatusMessage(translate(recordings.length === 1 ? 'recordings.startingDownloadOne' : 'recordings.startingDownloadMany', { count: recordings.length })); for (let i = 0; i < recordings.length; i++) { const r = recordings[i]; const url = `/api/recordings/download/${r.id}`; @@ -40,18 +41,18 @@ async function downloadZip(ids, filename, onProgress, onComplete, onError) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, filename }), }); - if (!resp.ok) throw new Error(`Server error ${resp.status}`); + if (!resp.ok) throw new Error(translate('recordings.serverError', { status: resp.status })); const { token, total } = await resp.json(); // Poll status while (true) { await new Promise(r => setTimeout(r, 800)); const st = await fetch(`/api/recordings/batch-download/status/${token}`); - if (!st.ok) throw new Error('Status poll failed'); + if (!st.ok) throw new Error(translate('recordings.statusPollFailed')); const { status, current } = await st.json(); onProgress(current, total); if (status === 'complete') break; - if (status === 'error') throw new Error('ZIP creation failed on server'); + if (status === 'error') throw new Error(translate('recordings.zipCreationFailed')); } // Trigger download @@ -63,7 +64,7 @@ async function downloadZip(ids, filename, onProgress, onComplete, onError) { document.body.removeChild(a); onComplete(); } catch (err) { - onError(err.message || 'Unknown error'); + onError(err.message || translate('recordings.unknownError')); } } @@ -77,6 +78,7 @@ async function downloadZip(ids, filename, onProgress, onComplete, onError) { * selectedIds {Object} – map of id → true for selected recordings */ export function BatchDownloadModal({ isOpen, onClose, recordings = [], selectedIds = {} }) { + const { t } = useI18n(); const [mode, setMode] = useState('zip'); // 'zip' | 'sequential' const [zipName, setZipName] = useState('recordings.zip'); const [busy, setBusy] = useState(false); @@ -116,7 +118,7 @@ export function BatchDownloadModal({ isOpen, onClose, recordings = [], selectedI ids, name.endsWith('.zip') ? name : name + '.zip', (cur, tot) => setProgress({ current: cur, total: tot }), - () => { setBusy(false); onClose(); showStatusMessage('ZIP download started'); }, + () => { setBusy(false); onClose(); showStatusMessage(t('recordings.zipDownloadStarted')); }, (msg) => { setBusy(false); setError(msg); } ); } @@ -129,9 +131,9 @@ export function BatchDownloadModal({ isOpen, onClose, recordings = [], selectedI return (
-

Download Selected Recordings

+

{t('recordings.downloadSelectedRecordings')}

- {selected.length} recording{selected.length !== 1 ? 's' : ''} selected + {t(selected.length === 1 ? 'recordings.recordingSelectedOne' : 'recordings.recordingSelectedMany', { count: selected.length })}

{/* Mode selector */} @@ -140,16 +142,16 @@ export function BatchDownloadModal({ isOpen, onClose, recordings = [], selectedI setMode('zip')} class="mt-0.5" />
-
ZIP archive (recommended)
-
All files packaged into a single .zip
+
{t('recordings.zipArchiveRecommended')}
+
{t('recordings.zipArchiveHelp')}
@@ -157,7 +159,7 @@ export function BatchDownloadModal({ isOpen, onClose, recordings = [], selectedI {/* ZIP filename input */} {mode === 'zip' && (
- +
- {mode === 'zip' ? 'Building ZIP…' : 'Downloading…'} + {t(mode === 'zip' ? 'recordings.buildingZip' : 'recordings.downloading')} {progress.current} / {progress.total}
@@ -196,14 +198,14 @@ export function BatchDownloadModal({ isOpen, onClose, recordings = [], selectedI onClick={onClose} disabled={busy} > - Cancel + {t('common.cancel')}
diff --git a/web/js/components/preact/DetectionOverlay.jsx b/web/js/components/preact/DetectionOverlay.jsx index 539408073..084aa8c54 100644 --- a/web/js/components/preact/DetectionOverlay.jsx +++ b/web/js/components/preact/DetectionOverlay.jsx @@ -4,6 +4,7 @@ */ import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; import { showStatusMessage } from './ToastContainer.jsx'; +import { t } from '../../i18n.js'; import { formatFilenameTimestamp } from '../../utils/date-utils.js'; import { displayedVideoBox } from './useLiveDisplayMode.js'; import { startDetectionPolling } from '../../utils/detection-polling.js'; @@ -266,7 +267,7 @@ export function drawDetectionsOnCanvas(ctx, width, height, detections) { */ export function takeSnapshotWithDetections(videoRef, canvasRef, streamName) { if (!videoRef.current || !canvasRef.current) { - showStatusMessage('Cannot take snapshot: Video not available', 'error'); + showStatusMessage(t('live.snapshotVideoUnavailable'), 'error'); return null; } @@ -279,7 +280,7 @@ export function takeSnapshotWithDetections(videoRef, canvasRef, streamName) { // Check if we have valid dimensions if (combinedCanvas.width === 0 || combinedCanvas.height === 0) { - showStatusMessage('Cannot take snapshot: Video not loaded or has invalid dimensions', 'error'); + showStatusMessage(t('live.snapshotInvalidDimensions'), 'error'); return null; } diff --git a/web/js/components/preact/FisheyeEptzCanvas.jsx b/web/js/components/preact/FisheyeEptzCanvas.jsx index 1a0958bb7..f374eb68a 100644 --- a/web/js/components/preact/FisheyeEptzCanvas.jsx +++ b/web/js/components/preact/FisheyeEptzCanvas.jsx @@ -496,7 +496,7 @@ export function FisheyeEptzCanvas({ videoRef, eptzConfig, streamName }) { style={{ zIndex: 8 }} title={rendererError} > - ePTZ unavailable + {t('live.eptzUnavailable')} )} diff --git a/web/js/components/preact/ManualRecordingButton.jsx b/web/js/components/preact/ManualRecordingButton.jsx index 2475ede90..4978131b2 100644 --- a/web/js/components/preact/ManualRecordingButton.jsx +++ b/web/js/components/preact/ManualRecordingButton.jsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useState } from 'preact/hooks'; import { showStatusMessage } from './ToastContainer.jsx'; +import { useI18n } from '../../i18n.js'; export function ManualRecordingButton({ streamName }) { + const { t } = useI18n(); const [status, setStatus] = useState(null); const [busy, setBusy] = useState(false); @@ -49,12 +51,12 @@ export function ManualRecordingButton({ streamName }) { throw new Error(data.error || data.message || `HTTP ${response.status}`); } showStatusMessage( - action === 'start' ? 'Manual recording started' : 'Manual recording stopped', + t(action === 'start' ? 'live.manualRecordingStarted' : 'live.manualRecordingStopped'), 'success' ); await refresh(); } catch (error) { - showStatusMessage(`Manual recording failed: ${error.message}`, 'error', 5000); + showStatusMessage(t('live.manualRecordingFailed', { message: error.message }), 'error', 5000); } finally { setBusy(false); } diff --git a/web/js/components/preact/RecordingsView.jsx b/web/js/components/preact/RecordingsView.jsx index 27b05eb50..ee2717c59 100644 --- a/web/js/components/preact/RecordingsView.jsx +++ b/web/js/components/preact/RecordingsView.jsx @@ -734,7 +734,7 @@ export function RecordingsView() { .filter(([_, sel]) => sel) .map(([id]) => id); if (selectedIds.length === 0) { - showStatusMessage('No recordings selected', 'warning'); + showStatusMessage(t('recordings.noRecordingsSelected'), 'warning'); return; } // Store current URL for "Refine Selections" back-link diff --git a/web/js/components/preact/SettingsView.jsx b/web/js/components/preact/SettingsView.jsx index 9be1f2612..1e1ec81bf 100644 --- a/web/js/components/preact/SettingsView.jsx +++ b/web/js/components/preact/SettingsView.jsx @@ -925,7 +925,7 @@ export function SettingsView() { >
{isDirty ? ( - {t('settings.unsavedChanges') !== 'settings.unsavedChanges' ? t('settings.unsavedChanges') : 'Unsaved changes'} + {t('settings.unsavedChanges')} ) : null} - — restrict detection-triggered clips independently + {t('streamsConfig.detectionScheduleHelpShort')} {currentStream.detectionRecordOnSchedule && (

- External events are still annotated outside these hours, but they will not start a detection clip. + {t('streamsConfig.detectionScheduleHelp')}

{t('streamsConfig.retentionDaysHelp')}

-

-1 = inherit the global setting

+

{t('streamsConfig.inheritGlobalSetting')}

diff --git a/web/js/components/preact/StreamsView.jsx b/web/js/components/preact/StreamsView.jsx index c2ca7359f..a560e4aa6 100644 --- a/web/js/components/preact/StreamsView.jsx +++ b/web/js/components/preact/StreamsView.jsx @@ -1613,7 +1613,7 @@ export function StreamsView() { - {t('streams.sortBy') || 'Sort by'}: + {t('streams.sortBy')}: {[ { key: 'name', label: t('common.name') }, { key: 'status', label: t('common.status') }, diff --git a/web/js/components/preact/UI.jsx b/web/js/components/preact/UI.jsx index d263fa605..8818d770c 100644 --- a/web/js/components/preact/UI.jsx +++ b/web/js/components/preact/UI.jsx @@ -7,7 +7,7 @@ import { createContext } from 'preact'; import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; import { createPortal } from 'preact/compat'; import { showStatusMessage } from './ToastContainer.jsx'; -import { useI18n } from '../../i18n.js'; +import { t as translate, useI18n } from '../../i18n.js'; import { formatUtils } from './recordings/formatUtils.js'; import { formatFilenameTimestamp, nowMilliseconds } from '../../utils/date-utils.js'; import dayjs from 'dayjs'; @@ -54,23 +54,24 @@ export const ModalContext = createContext({ * @returns {JSX.Element} DeleteConfirmationModal component */ export function DeleteConfirmationModal(props) { + const { t } = useI18n(); const { isOpen, onClose, onConfirm, mode, count, recordingName } = props; if (!isOpen) return null; // Determine title and message based on mode - let title = 'Confirm Delete'; - let message = 'Are you sure you want to delete this item?'; + let title = t('recordings.confirmDelete'); + let message = t('recordings.confirmDeleteItem'); if (mode === 'single') { - title = 'Delete Recording'; - message = `Are you sure you want to delete this recording from ${recordingName || 'this stream'}? This action cannot be undone.`; + title = t('timeline.deleteRecording'); + message = t('recordings.confirmDeleteFromStream', { stream: recordingName || t('recordings.thisStream') }); } else if (mode === 'selected') { - title = 'Delete Selected Recordings'; - message = `Are you sure you want to delete ${count} selected recording${count !== 1 ? 's' : ''}?`; + title = t('recordings.deleteSelectedRecordings'); + message = t(count === 1 ? 'recordings.confirmDeleteSelectedOne' : 'recordings.confirmDeleteSelectedMany', { count }); } else if (mode === 'all') { - title = 'Delete All Filtered Recordings'; - message = 'Are you sure you want to delete all recordings matching the current filters? This action cannot be undone.'; + title = t('recordings.deleteAllFilteredRecordings'); + message = t('recordings.confirmDeleteAllFiltered'); } // Handle escape key @@ -105,13 +106,13 @@ export function DeleteConfirmationModal(props) { class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600" onClick={onClose} > - Cancel + {t('common.cancel')}
@@ -129,6 +130,7 @@ export function DeleteConfirmationModal(props) { * @returns {JSX.Element} SnapshotPreviewModal component */ export function SnapshotPreviewModal({ isOpen, onClose, imageData, streamName, onDownload }) { + const { t } = useI18n(); const modalRef = useRef(null); useEffect(() => { @@ -176,11 +178,12 @@ export function SnapshotPreviewModal({ isOpen, onClose, imageData, streamName, o

- {streamName ? `Snapshot: ${streamName}` : 'Snapshot'} + {streamName ? t('recordings.snapshotOfStream', { stream: streamName }) : t('timeline.snapshot')}

@@ -190,7 +193,7 @@ export function SnapshotPreviewModal({ isOpen, onClose, imageData, streamName, o id="snapshot-preview-image" className="max-w-full max-h-[70vh] mx-auto" src={imageData} - alt="Snapshot" + alt={t('timeline.snapshot')} />
@@ -199,14 +202,14 @@ export function SnapshotPreviewModal({ isOpen, onClose, imageData, streamName, o className="btn-primary" onClick={onDownload} > - Download + {t('common.download')}
@@ -231,7 +234,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { const [timeWindow, setTimeWindow] = useState(2); const [detections, setDetections] = useState([]); const [recordingData, setRecordingData] = useState(null); - const [detectionStatus, setDetectionStatus] = useState('No detections loaded'); + const [detectionStatus, setDetectionStatus] = useState(() => t('recordings.noDetectionsLoaded')); const [currentSpeed, setCurrentSpeed] = useState(1.0); const [currentPlaybackSeconds, setCurrentPlaybackSeconds] = useState(0); const [isFullscreen, setIsFullscreen] = useState(false); @@ -310,7 +313,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { if (startTime === 0 || endTime === 0) { console.error('Failed to parse recording timestamps:', data.start_time, data.end_time); - setDetectionStatus('Error: Invalid recording timestamps'); + setDetectionStatus(t('recordings.invalidRecordingTimestamps')); return; } @@ -327,14 +330,14 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { setDetections(fetchedDetections); if (fetchedDetections.length > 0) { - setDetectionStatus(`${fetchedDetections.length} detection${fetchedDetections.length !== 1 ? 's' : ''} available`); + setDetectionStatus(t(fetchedDetections.length === 1 ? 'recordings.detectionAvailableOne' : 'recordings.detectionAvailableMany', { count: fetchedDetections.length })); } else { - setDetectionStatus('No detections found for this recording'); + setDetectionStatus(t('recordings.noDetectionsForRecording')); } } } catch (error) { console.error('Error fetching data:', error); - setDetectionStatus('Error loading detections'); + setDetectionStatus(t('recordings.errorLoadingDetections')); } }; @@ -366,10 +369,10 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ protected: newState }), }); - if (!response.ok) throw new Error(`Failed to ${newState ? 'protect' : 'unprotect'} recording`); + if (!response.ok) throw new Error(t(newState ? 'recordings.protectFailed' : 'recordings.unprotectFailed')); setIsProtected(newState); showStatusMessage( - newState ? 'Recording protected from automatic deletion' : 'Recording protection removed', + t(newState ? 'recordings.recordingProtected' : 'recordings.recordingProtectionRemoved'), 'success' ); @@ -379,7 +382,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { })); } catch (error) { console.error('Error toggling protection:', error); - showStatusMessage(`Error: ${error.message}`, 'error'); + showStatusMessage(t('recordings.errorMessage', { message: error.message }), 'error'); } }, [recordingId, isProtected]); @@ -388,8 +391,8 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { if (!recordingId) return; try { const response = await fetch(`/api/recordings/${recordingId}`, { method: 'DELETE' }); - if (!response.ok) throw new Error('Failed to delete recording'); - showStatusMessage('Recording deleted successfully', 'success'); + if (!response.ok) throw new Error(t('timeline.failedToDeleteRecording')); + showStatusMessage(t('timeline.recordingDeletedSuccessfully'), 'success'); setShowDeleteConfirm(false); // Notify RecordingsView to remove the recording locally @@ -400,7 +403,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { onClose(); } catch (error) { console.error('Error deleting recording:', error); - showStatusMessage(`Error: ${error.message}`, 'error'); + showStatusMessage(t('recordings.errorMessage', { message: error.message }), 'error'); setShowDeleteConfirm(false); } }, [recordingId, onClose]); @@ -410,7 +413,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { if (!videoRef.current) return; const video = videoRef.current; if (!video.videoWidth || !video.videoHeight) { - showStatusMessage('Cannot take snapshot: Video not loaded', 'error'); + showStatusMessage(t('timeline.cannotTakeSnapshotVideoNotLoaded'), 'error'); return; } const canvas = document.createElement('canvas'); @@ -425,7 +428,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { canvas.toBlob((blob) => { if (!blob) { - showStatusMessage('Failed to create snapshot', 'error'); + showStatusMessage(t('timeline.failedToCreateSnapshot'), 'error'); return; } const blobUrl = URL.createObjectURL(blob); @@ -438,7 +441,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { if (document.body.contains(link)) document.body.removeChild(link); URL.revokeObjectURL(blobUrl); }, 1000); - showStatusMessage(`Snapshot saved: ${fileName}`, 'success', 2000); + showStatusMessage(t('recordings.snapshotSaved', { fileName }), 'success', 2000); }, 'image/jpeg', 0.95); }, [recordingData]); @@ -507,9 +510,9 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { // Update status if (visibleDetections.length > 0) { - setDetectionStatus(`Showing ${visibleDetections.length} detection${visibleDetections.length !== 1 ? 's' : ''} at current time`); + setDetectionStatus(t(visibleDetections.length === 1 ? 'recordings.showingDetectionOne' : 'recordings.showingDetectionMany', { count: visibleDetections.length })); } else { - setDetectionStatus(`No detections at current time (${detections.length} total)`); + setDetectionStatus(t('recordings.noDetectionsAtCurrentTime', { count: detections.length })); } // Draw each visible detection using letterbox-aware coordinates @@ -604,7 +607,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { const video = videoRef.current; setPlaybackMessage(''); return loadRecordingPlayback(video, videoUrl, { - onPreparing: () => setPlaybackMessage('Preparing a compatible version for this browser…'), + onPreparing: () => setPlaybackMessage(t('recordings.preparingCompatiblePlayback')), onReady: () => setPlaybackMessage(''), onError: error => setPlaybackMessage(error.message), }); @@ -708,7 +711,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { } } catch (error) { console.error('Error toggling fullscreen:', error); - showStatusMessage(`Could not toggle fullscreen mode: ${error.message}`, 'error'); + showStatusMessage(t('timeline.couldNotToggleFullscreen', { message: error.message }), 'error'); } }, []); @@ -774,11 +777,12 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) {

- {title || 'Video'} + {title || t('recordings.video')}

@@ -827,7 +831,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) {

- PLAYBACK CONTROLS + {t('recordings.playbackControls')}

@@ -835,9 +839,9 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { type="button" className="px-3 py-1.5 rounded bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors text-sm font-medium" onClick={handleToggleFullscreen} - title={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'} + title={t(isFullscreen ? 'timeline.exitFullscreen' : 'timeline.enterFullscreen')} > - {isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'} + {t(isFullscreen ? 'timeline.exitFullscreen' : 'timeline.fullscreen')}
@@ -845,7 +849,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { {/* Speed controls section */}

- Playback Speed + {t('recordings.playbackSpeed')}

@@ -860,20 +864,20 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { data-speed={speed} onClick={() => handleSpeedChange(speed)} > - {speed === 1.0 ? '1× (Normal)' : `${speed}×`} + {speed === 1.0 ? t('recordings.normalSpeed') : `${speed}×`} ))}
- Current Speed: {currentSpeed}× {currentSpeed === 1.0 ? '(Normal)' : ''} + {t('recordings.currentSpeed', { speed: `${currentSpeed}×` })} {currentSpeed === 1.0 ? t('recordings.normalSpeedSuffix') : ''}
{/* Detection overlay section */}

- Detection Overlays + {t('recordings.detectionOverlays')}

@@ -890,7 +894,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { htmlFor="detection-overlay-checkbox" className="text-xs font-medium text-foreground" > - Show Detection Overlays + {t('recordings.showDetectionOverlays')}
@@ -899,7 +903,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { htmlFor="detection-sensitivity-slider" className="text-xs font-medium text-foreground mb-1" > - Detection Sensitivity + {t('recordings.detectionSensitivity')}
- Time Window: {timeWindow} second{timeWindow !== 1 ? 's' : ''} + {t(timeWindow === 1 ? 'recordings.timeWindowOne' : 'recordings.timeWindowMany', { count: timeWindow })}
@@ -941,7 +945,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { - Snapshot + {t('timeline.snapshot')} {/* Download */} {downloadUrl && ( @@ -953,7 +957,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { - Download + {t('common.download')} )} {/* View in Timeline */} @@ -975,7 +979,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { - Timeline + {t('nav.timeline')} ); })()} @@ -988,7 +992,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { : 'bg-secondary text-secondary-foreground hover:bg-secondary/80' }`} onClick={handleToggleProtection} - title={isProtected ? 'Unprotect Recording' : 'Protect Recording'} + title={t(isProtected ? 'recordings.unprotect' : 'recordings.protect')} > {isProtected ? ( @@ -997,7 +1001,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { )} - {isProtected ? 'Protected' : 'Protect'} + {t(isProtected ? 'recordings.protected' : 'recordings.protectAction')} )} {/* Delete */} @@ -1010,7 +1014,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { - Delete + {t('common.delete')} )}
@@ -1021,8 +1025,8 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { onClose={() => setShowDeleteConfirm(false)} onConfirm={handleDeleteRecording} title={t('timeline.deleteRecording')} - message="Are you sure you want to delete this recording? This action cannot be undone." - confirmLabel="Delete" + message={t('timeline.deleteRecordingConfirmation')} + confirmLabel={t('common.delete')} />
@@ -1037,6 +1041,7 @@ export function VideoModal({ isOpen, onClose, videoUrl, title, downloadUrl }) { * Provides modal context and renders modal components */ export function ModalProvider({ children }) { + const { t } = useI18n(); const [snapshotModal, setSnapshotModal] = useState({ isOpen: false, imageData: '', @@ -1079,7 +1084,7 @@ export function ModalProvider({ children }) { if (!imageData) { console.error('No image data available for download'); - showStatusMessage('Download failed: No image data available', 'error', 5000); + showStatusMessage(t('recordings.snapshotDownloadMissingData'), 'error', 5000); return; } @@ -1099,10 +1104,10 @@ export function ModalProvider({ children }) { } }, 100); - showStatusMessage('Snapshot downloaded successfully', 'success', 3000); + showStatusMessage(t('recordings.snapshotDownloaded'), 'success', 3000); } catch (error) { console.error('Error in snapshot download process:', error); - showStatusMessage('Download failed: ' + error.message, 'error', 5000); + showStatusMessage(t('recordings.downloadFailed', { message: error.message }), 'error', 5000); } }, [snapshotModal]); @@ -1201,7 +1206,7 @@ export function showVideoModal(videoUrl, title, downloadUrl) { ); }).catch(err => { console.error('Error rendering video modal:', err); - showStatusMessage('Error showing video modal: ' + err.message, 'error'); + showStatusMessage(translate('recordings.errorShowingVideo', { message: err.message }), 'error'); }); } @@ -1247,10 +1252,10 @@ export function showSnapshotPreview(imageData, streamName) { } }, 100); - showStatusMessage('Snapshot downloaded successfully', 'success', 3000); + showStatusMessage(translate('recordings.snapshotDownloaded'), 'success', 3000); } catch (error) { console.error('Error downloading snapshot:', error); - showStatusMessage('Download failed: ' + error.message, 'error', 5000); + showStatusMessage(translate('recordings.downloadFailed', { message: error.message }), 'error', 5000); } }; @@ -1269,6 +1274,6 @@ export function showSnapshotPreview(imageData, streamName) { ); }).catch(err => { console.error('Error rendering snapshot modal:', err); - showStatusMessage('Error showing snapshot preview: ' + err.message, 'error'); + showStatusMessage(translate('recordings.errorShowingSnapshot', { message: err.message }), 'error'); }); } diff --git a/web/js/components/preact/WebRTCVideoCell.jsx b/web/js/components/preact/WebRTCVideoCell.jsx index 2ccdf9e28..56c12a2fc 100644 --- a/web/js/components/preact/WebRTCVideoCell.jsx +++ b/web/js/components/preact/WebRTCVideoCell.jsx @@ -1720,7 +1720,7 @@ export function WebRTCVideoCell({ opacity: 0.7 }} > - {talkMode === 'ptt' ? 'PTT' : 'TOG'} + {t(talkMode === 'ptt' ? 'live.pushToTalkShort' : 'live.toggleTalkShort')} {/* Main microphone button */} ); diff --git a/web/js/components/preact/recordings/RecordingsGrid.jsx b/web/js/components/preact/recordings/RecordingsGrid.jsx index 9ed7071bb..7439c333e 100644 --- a/web/js/components/preact/recordings/RecordingsGrid.jsx +++ b/web/js/components/preact/recordings/RecordingsGrid.jsx @@ -258,7 +258,7 @@ function RecordingCard({ - Loading... + {t('common.loading')} ) : (
- Tap to retry + {t('recordings.tapToRetry')}
)} @@ -348,7 +348,7 @@ function RecordingCard({
- {recording.stream || 'Unknown'} + {recording.stream || t('common.unknown')} {formatUtils.formatDateTime(recording.start_time_unix ?? recording.start_time)} diff --git a/web/js/components/preact/recordings/RecordingsTable.jsx b/web/js/components/preact/recordings/RecordingsTable.jsx index 87d081b2c..daf6a46f2 100644 --- a/web/js/components/preact/recordings/RecordingsTable.jsx +++ b/web/js/components/preact/recordings/RecordingsTable.jsx @@ -22,6 +22,7 @@ const HIDEABLE_COLUMNS = [ * Inline tag cell with overlay toggle */ function TagCell({ recording, onTagsChanged }) { + const { t } = useI18n(); const [showOverlay, setShowOverlay] = useState(false); return ( @@ -35,7 +36,7 @@ function TagCell({ recording, onTagsChanged }) { @@ -396,7 +397,7 @@ export function RecordingsTable({ - Yes + {t('common.yes')} ) : ''} diff --git a/web/js/components/preact/recordings/TagsOverlay.jsx b/web/js/components/preact/recordings/TagsOverlay.jsx index 93fe283ba..6892258af 100644 --- a/web/js/components/preact/recordings/TagsOverlay.jsx +++ b/web/js/components/preact/recordings/TagsOverlay.jsx @@ -88,7 +88,7 @@ export function TagsOverlay({ recording, onClose, onTagsChanged }) { try { await recordingsAPI.setRecordingTags(recording.id, newTags); if (onTagsChanged) onTagsChanged(recording.id, newTags); - showStatusMessage(`Tag "${trimmed}" added`); + showStatusMessage(t('recordings.tagAdded', { tag: trimmed })); } catch { /* error shown by API */ } setSaving(false); }, [tags, recording.id, onTagsChanged]); @@ -100,7 +100,7 @@ export function TagsOverlay({ recording, onClose, onTagsChanged }) { try { await recordingsAPI.setRecordingTags(recording.id, newTags); if (onTagsChanged) onTagsChanged(recording.id, newTags); - showStatusMessage(`Tag "${tag}" removed`); + showStatusMessage(t('recordings.tagRemoved', { tag })); } catch { /* error shown by API */ } setSaving(false); }, [tags, recording.id, onTagsChanged]); @@ -122,14 +122,14 @@ export function TagsOverlay({ recording, onClose, onTagsChanged }) { >
- Recording Tags + {t('recordings.recordingTags')}
{/* Current tags */}
- {tags.length === 0 && No tags} + {tags.length === 0 && {t('recordings.noTags')}} {tags.map(tag => )}
@@ -226,12 +226,12 @@ export function BulkTagsOverlay({ recordings, selectedRecordings, onClose, onTag // Remove from all await recordingsAPI.batchUpdateRecordingTags(selectedIds, [], [tag]); setTagStates(prev => ({ ...prev, [tag]: 'none' })); - showStatusMessage(`Tag "${tag}" removed from ${selectedIds.length} recordings`); + showStatusMessage(t('recordings.tagRemovedFromRecordings', { tag, count: selectedIds.length })); } else { // 'none' or 'some' -> add to all await recordingsAPI.batchUpdateRecordingTags(selectedIds, [tag], []); setTagStates(prev => ({ ...prev, [tag]: 'all' })); - showStatusMessage(`Tag "${tag}" added to ${selectedIds.length} recordings`); + showStatusMessage(t('recordings.tagAddedToRecordings', { tag, count: selectedIds.length })); } if (onTagsChanged) onTagsChanged(); } catch { /* error shown by API */ } @@ -248,7 +248,7 @@ export function BulkTagsOverlay({ recordings, selectedRecordings, onClose, onTag if (!allTags.includes(trimmed)) setAllTags(prev => [...prev, trimmed].sort()); setInputValue(''); setShowSuggestions(false); - showStatusMessage(`Tag "${trimmed}" added to ${selectedIds.length} recordings`); + showStatusMessage(t('recordings.tagAddedToRecordings', { tag: trimmed, count: selectedIds.length })); if (onTagsChanged) onTagsChanged(); } catch { /* error shown by API */ } setSaving(false); @@ -271,14 +271,14 @@ export function BulkTagsOverlay({ recordings, selectedRecordings, onClose, onTag >
- Recording Tags ({selectedIds.length} recordings) + {t('recordings.recordingTagsCount', { count: selectedIds.length })}
{/* Tag list with tri-state checkboxes */}
- {knownTags.length === 0 && No tags yet} + {knownTags.length === 0 && {t('recordings.noTagsYet')}} {knownTags.map(tag => { const state = tagStates[tag]; return ( diff --git a/web/js/components/preact/recordings/recordingsAPI.jsx b/web/js/components/preact/recordings/recordingsAPI.jsx index 93e758d13..6256bcc81 100644 --- a/web/js/components/preact/recordings/recordingsAPI.jsx +++ b/web/js/components/preact/recordings/recordingsAPI.jsx @@ -4,6 +4,7 @@ import { batchDeleteStatus } from '../../../utils/batch-delete-status.js'; import { showStatusMessage } from '../ToastContainer.jsx'; +import { t } from '../../../i18n.js'; import { prepareRecordingPlayback } from '../../../utils/recording-playback.js'; import { formatUtils } from './formatUtils.js'; import { urlUtils } from './urlUtils.js'; @@ -234,11 +235,11 @@ export const recordingsAPI = { onSuccess: () => { // Invalidate recordings queries to refresh the list queryClient.invalidateQueries({ queryKey: ['recordings'] }); - showStatusMessage('Recording deleted successfully'); + showStatusMessage(t('timeline.recordingDeletedSuccessfully')); }, onError: (error) => { console.error('Error deleting recording:', error); - showStatusMessage('Error deleting recording: ' + error.message); + showStatusMessage(t('recordings.errorDeletingRecording', { message: error.message })); } }); }, @@ -268,7 +269,7 @@ export const recordingsAPI = { }, onError: (error) => { console.error('Error in batch delete operation:', error); - showStatusMessage('Error in batch delete operation: ' + error.message); + showStatusMessage(t('recordings.errorBatchDelete', { message: error.message })); } }); } @@ -284,7 +285,7 @@ export const recordingsAPI = { return data || []; } catch (error) { console.error('Error loading streams for filter:', error); - showStatusMessage('Error loading streams: ' + error.message); + showStatusMessage(t('recordings.errorLoadingStreams', { message: error.message })); return []; } }, @@ -366,7 +367,7 @@ export const recordingsAPI = { return data; } catch (error) { console.error('Error loading recordings:', error); - showStatusMessage('Error loading recordings: ' + error.message); + showStatusMessage(t('recordings.errorLoadingRecordings', { message: error.message })); throw error; } }, @@ -385,11 +386,11 @@ export const recordingsAPI = { retryDelay: 1000 // 1 second between retries }); - showStatusMessage('Recording deleted successfully'); + showStatusMessage(t('timeline.recordingDeletedSuccessfully')); return true; } catch (error) { console.error('Error deleting recording:', error); - showStatusMessage('Error deleting recording: ' + error.message); + showStatusMessage(t('recordings.errorDeletingRecording', { message: error.message })); return false; } }, @@ -405,7 +406,7 @@ export const recordingsAPI = { .map(([id]) => parseInt(id, 10)); if (selectedIds.length === 0) { - showStatusMessage('No recordings selected'); + showStatusMessage(t('recordings.noRecordingsSelected')); return { succeeded: 0, failed: 0 }; } @@ -418,7 +419,7 @@ export const recordingsAPI = { return recordingsAPI.deleteSelectedRecordingsHttp(selectedIds); } catch (error) { console.error('Error in batch delete operation:', error); - showStatusMessage('Error in batch delete operation: ' + error.message); + showStatusMessage(t('recordings.errorBatchDelete', { message: error.message })); return { succeeded: 0, failed: 0 }; } }, @@ -447,7 +448,7 @@ export const recordingsAPI = { return await recordingsAPI.handleBatchDeleteResponse(response); } catch (error) { console.error('Error in HTTP batch delete operation:', error); - showStatusMessage('Error in batch delete operation: ' + error.message); + showStatusMessage(t('recordings.errorBatchDelete', { message: error.message })); return { succeeded: 0, failed: 0 }; } }, @@ -560,7 +561,7 @@ export const recordingsAPI = { return recordingsAPI.deleteAllFilteredRecordingsHttp(filter); } catch (error) { console.error('Error in delete all operation:', error); - showStatusMessage('Error in delete all operation: ' + error.message); + showStatusMessage(t('recordings.errorDeleteAll', { message: error.message })); return { succeeded: 0, failed: 0 }; } }, @@ -603,7 +604,7 @@ export const recordingsAPI = { return await recordingsAPI.handleBatchDeleteResponse(deleteResponse); } catch (error) { console.error('Error in HTTP delete all operation:', error); - showStatusMessage('Error in delete all operation: ' + error.message); + showStatusMessage(t('recordings.errorDeleteAll', { message: error.message })); return { succeeded: 0, failed: 0 }; } }, @@ -681,7 +682,7 @@ export const recordingsAPI = { // Check if recording has an id property if (!recording.id) { console.error('Recording has no id property:', recording); - showStatusMessage('Error: Recording has no id property'); + showStatusMessage(t('recordings.recordingMissingId')); return; } @@ -783,7 +784,7 @@ export const recordingsAPI = { return data; } catch (error) { console.error('Error setting recording tags:', error); - showStatusMessage('Error setting tags: ' + error.message); + showStatusMessage(t('recordings.errorSettingTags', { message: error.message })); throw error; } }, @@ -808,7 +809,7 @@ export const recordingsAPI = { return data; } catch (error) { console.error('Error in batch tag update:', error); - showStatusMessage('Error updating tags: ' + error.message); + showStatusMessage(t('recordings.errorUpdatingTags', { message: error.message })); throw error; } }, @@ -823,7 +824,7 @@ export const recordingsAPI = { await prepareRecordingPlayback(downloadUrl, { compatibility: false, signal: AbortSignal.timeout(60 * 60 * 1000), - onWaiting: () => showStatusMessage('Preparing archived recording…'), + onWaiting: () => showStatusMessage(t('recordings.preparingArchivedRecording')), }); } catch (error) { showStatusMessage(error.message || 'Unable to retrieve recording', 'error'); @@ -844,6 +845,6 @@ export const recordingsAPI = { link.click(); document.body.removeChild(link); - showStatusMessage('Download started'); + showStatusMessage(t('recordings.downloadStarted')); } }; diff --git a/web/js/components/preact/settings/AuthTab.jsx b/web/js/components/preact/settings/AuthTab.jsx index ed5f766d4..cb742172c 100644 --- a/web/js/components/preact/settings/AuthTab.jsx +++ b/web/js/components/preact/settings/AuthTab.jsx @@ -33,7 +33,7 @@ export function AuthTab({
{/* Authentication & access */}
-

{t('settings.authentication') || 'Authentication & Access'}

+

{t('settings.authentication')}

diff --git a/web/js/components/preact/settings/Go2rtcTab.jsx b/web/js/components/preact/settings/Go2rtcTab.jsx index 479403b62..ad0df31e9 100644 --- a/web/js/components/preact/settings/Go2rtcTab.jsx +++ b/web/js/components/preact/settings/Go2rtcTab.jsx @@ -461,7 +461,7 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS {settings.go2rtcOverrideDisabledReason ? (
- Override quarantined + {t('settings.go2rtcOverrideQuarantined')}
{settings.go2rtcOverrideDisabledReason} @@ -478,7 +478,7 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS setOverrideValidation(null); }} > - Restore quarantined version into the editor + {t('settings.go2rtcRestoreQuarantined')} ) : null}
@@ -502,10 +502,10 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS
{formatBytes(utf8ByteLength(settings.go2rtcConfigOverride))} / {formatBytes(GO2RTC_OVERRIDE_MAX_BYTES)} - {overrideValidating ? ' • validating…' : null} + {overrideValidating ? t('settings.go2rtcValidating') : null} {overrideValidation && overrideValidation.libyaml_available === false ? ( - libyaml unavailable — server-side validation skipped + {t('settings.go2rtcValidationSkipped')} ) : null}
@@ -513,7 +513,7 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS {overrideValidation && overrideValidation.valid === false && overrideValidation.error ? (
- Line {overrideValidation.error.line || '?'}, col {overrideValidation.error.column || '?'} + {t('settings.go2rtcErrorPosition', { line: overrideValidation.error.line || '?', column: overrideValidation.error.column || '?' })}
{overrideValidation.error.message}
@@ -536,7 +536,7 @@ export function Go2rtcTab({ settings, setSettings, handleInputChange, canModifyS disabled={!canModifySettings} onClick={loadEffectiveConfig} > - Show effective config + {t('settings.go2rtcShowEffectiveConfig')}