diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index bd27048..c899fd3 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -40,3 +40,10 @@ jobs:
- name: Run tests (${{ matrix.name }})
run: npm test
working-directory: ${{ matrix.path }}
+
+ # Every English key must exist in every other language (issue #137), so a
+ # new string can't ship as untranslated text in a translated UI.
+ - name: Check translation completeness
+ if: matrix.name == 'frontend'
+ run: npm run check:i18n
+ working-directory: ${{ matrix.path }}
diff --git a/client/package-lock.json b/client/package-lock.json
index 87c7047..d2d29d5 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -14,12 +14,14 @@
"@mui/material": "^9.2.0",
"axios": "^1.15.2",
"cron-parser": "^5.5.0",
+ "i18next": "^26.3.6",
+ "i18next-browser-languagedetector": "^8.2.1",
"moment": "^2.30.1",
"react": "^19.2.1",
- "react-big-calendar": "^1.19.4",
"react-color": "2.19.3",
"react-dom": "^19.2.1",
"react-grid-layout": "^2.1.0",
+ "react-i18next": "^17.0.11",
"react-rnd": "^10.5.2",
"recharts": "^3.9.2"
},
@@ -701,18 +703,6 @@
}
}
},
- "node_modules/@restart/hooks": {
- "version": "0.4.16",
- "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz",
- "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==",
- "license": "MIT",
- "dependencies": {
- "dequal": "^2.0.3"
- },
- "peerDependencies": {
- "react": ">=16.8.0"
- }
- },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
@@ -1123,6 +1113,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1142,12 +1133,6 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
- "node_modules/@types/warning": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.4.tgz",
- "integrity": "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg==",
- "license": "MIT"
- },
"node_modules/@vitejs/plugin-react": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
@@ -1431,11 +1416,6 @@
"node": ">=18"
}
},
- "node_modules/cldrjs": {
- "version": "0.5.5",
- "resolved": "https://registry.npmjs.org/cldrjs/-/cldrjs-0.5.5.tgz",
- "integrity": "sha512-KDwzwbmLIPfCgd8JERVDpQKrUUM1U4KpFJJg2IROv89rF172lLufoJnqJ/Wea6fXL5bO6WjuLMzY8V52UWPvkA=="
- },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1627,18 +1607,6 @@
"node": ">=12"
}
},
- "node_modules/date-arithmetic": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz",
- "integrity": "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==",
- "license": "MIT"
- },
- "node_modules/dayjs": {
- "version": "1.11.21",
- "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
- "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
- "license": "MIT"
- },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1671,15 +1639,6 @@
"node": ">=0.4.0"
}
},
- "node_modules/dequal": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
- "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1690,16 +1649,6 @@
"node": ">=8"
}
},
- "node_modules/dom-helpers": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-6.0.1.tgz",
- "integrity": "sha512-IKySryuFwseGkrCA/pIqlwUPOD50w1Lj/B2Yief3vBOP18k5y4t+hTqKh55gULDVeJMRitcozve+g/wVFf4sFQ==",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.27.1",
- "csstype": "^3.1.3"
- }
- },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1950,15 +1899,6 @@
"node": ">= 0.4"
}
},
- "node_modules/globalize": {
- "version": "1.7.1",
- "resolved": "https://registry.npmjs.org/globalize/-/globalize-1.7.1.tgz",
- "integrity": "sha512-PFymRL0PtitFOlSniuwwwNfkooi3cLQJo9Uke1+j1DsGfUkkHkwneImqVtGcqKI0TuzhAlHt7hAcgK324902HA==",
- "license": "MIT",
- "dependencies": {
- "cldrjs": "^0.5.4"
- }
- },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -2042,6 +1982,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/html-parse-stringify": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
+ "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://locize.com"
+ }
+ },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -2055,6 +2004,43 @@
"node": ">= 6"
}
},
+ "node_modules/i18next": {
+ "version": "26.3.6",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
+ "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/i18next-browser-languagedetector": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
+ "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ }
+ },
"node_modules/immer": {
"version": "11.1.9",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz",
@@ -2090,15 +2076,6 @@
"node": ">=12"
}
},
- "node_modules/invariant": {
- "version": "2.2.4",
- "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
- "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.0.0"
- }
- },
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -2548,12 +2525,6 @@
"node": ">= 0.4"
}
},
- "node_modules/memoize-one": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
- "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
- "license": "MIT"
- },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -2584,18 +2555,6 @@
"node": "*"
}
},
- "node_modules/moment-timezone": {
- "version": "0.5.48",
- "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
- "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
- "license": "MIT",
- "dependencies": {
- "moment": "^2.29.4"
- },
- "engines": {
- "node": "*"
- }
- },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2789,34 +2748,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/react-big-calendar": {
- "version": "1.20.0",
- "resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.20.0.tgz",
- "integrity": "sha512-Lp1mvG34l/9xtb/2LsBb4UAF3iPcUkmDqbmpsvDHzp/n8GA5gtn3+nf8BsULWw08opKDgv38nQi75dQlOOqzkg==",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.29.7",
- "clsx": "^2.1.1",
- "date-arithmetic": "^4.1.0",
- "dayjs": "^1.11.21",
- "dom-helpers": "^6.0.1",
- "globalize": "^1.7.1",
- "invariant": "^2.2.4",
- "lodash": "^4.18.1",
- "lodash-es": "^4.18.1",
- "luxon": "^3.7.2",
- "memoize-one": "^6.0.0",
- "moment": "^2.29.4",
- "moment-timezone": "^0.5.48",
- "prop-types": "^15.8.1",
- "react-overlays": "^5.2.1",
- "uncontrollable": "^7.2.1"
- },
- "peerDependencies": {
- "react": "^16.14.0 || ^17 || ^18 || ^19",
- "react-dom": "^16.14.0 || ^17 || ^18 || ^19"
- }
- },
"node_modules/react-color": {
"version": "2.19.3",
"resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
@@ -2879,47 +2810,38 @@
"react-dom": ">= 16.3.0"
}
},
- "node_modules/react-is": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
- "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
- "license": "MIT"
- },
- "node_modules/react-lifecycles-compat": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
- "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
- "license": "MIT"
- },
- "node_modules/react-overlays": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz",
- "integrity": "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==",
+ "node_modules/react-i18next": {
+ "version": "17.0.11",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
+ "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.13.8",
- "@popperjs/core": "^2.11.6",
- "@restart/hooks": "^0.4.7",
- "@types/warning": "^3.0.0",
- "dom-helpers": "^5.2.0",
- "prop-types": "^15.7.2",
- "uncontrollable": "^7.2.1",
- "warning": "^4.0.3"
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^4.0.1",
+ "use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
- "react": ">=16.3.0",
- "react-dom": ">=16.3.0"
+ "i18next": ">= 26.2.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
}
},
- "node_modules/react-overlays/node_modules/dom-helpers": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
- "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.8.7",
- "csstype": "^3.0.2"
- }
+ "node_modules/react-is": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
+ "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
+ "license": "MIT"
},
"node_modules/react-redux": {
"version": "9.3.0",
@@ -3281,21 +3203,6 @@
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"license": "0BSD"
},
- "node_modules/uncontrollable": {
- "version": "7.2.1",
- "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
- "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.6.3",
- "@types/react": ">=16.9.11",
- "invariant": "^2.2.4",
- "react-lifecycles-compat": "^3.0.4"
- },
- "peerDependencies": {
- "react": ">=15.0.0"
- }
- },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -3495,15 +3402,6 @@
}
}
},
- "node_modules/warning": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
- "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.0.0"
- }
- },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
diff --git a/client/package.json b/client/package.json
index 8e4ae90..19765c7 100644
--- a/client/package.json
+++ b/client/package.json
@@ -7,6 +7,7 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
+ "check:i18n": "node scripts/checkTranslations.js",
"test:watch": "vitest",
"test:coverage": "npm run test:clean && vitest run --coverage",
"test:clean": "node -e \"const fs=require('node:fs'); if (fs.existsSync('coverage')) fs.rmSync('coverage',{recursive:true,force:true});\""
@@ -18,12 +19,14 @@
"@mui/material": "^9.2.0",
"axios": "^1.15.2",
"cron-parser": "^5.5.0",
+ "i18next": "^26.3.6",
+ "i18next-browser-languagedetector": "^8.2.1",
"moment": "^2.30.1",
"react": "^19.2.1",
- "react-big-calendar": "^1.19.4",
"react-color": "2.19.3",
"react-dom": "^19.2.1",
"react-grid-layout": "^2.1.0",
+ "react-i18next": "^17.0.11",
"react-rnd": "^10.5.2",
"recharts": "^3.9.2"
},
diff --git a/client/scripts/checkTranslations.js b/client/scripts/checkTranslations.js
new file mode 100644
index 0000000..0edfe50
--- /dev/null
+++ b/client/scripts/checkTranslations.js
@@ -0,0 +1,82 @@
+// Completeness check for translations (issue #137).
+//
+// English is the source of truth: every key under locales/en must exist in
+// every other language, with no extra keys that English does not have. Run in
+// CI so a new English string can't silently ship as untranslated text in
+// another language.
+//
+// node scripts/checkTranslations.js report and exit non-zero on gaps
+// node scripts/checkTranslations.js --report report only, always exit 0
+const fs = require('node:fs');
+const path = require('node:path');
+
+const localesDir = path.join(__dirname, '..', 'src', 'i18n', 'locales');
+const reportOnly = process.argv.includes('--report');
+
+// Flatten to dotted paths so nesting differences show up as key differences.
+const flatten = (obj, prefix = '') => {
+ const out = [];
+ for (const [key, value] of Object.entries(obj)) {
+ const full = prefix ? `${prefix}.${key}` : key;
+ if (value && typeof value === 'object' && !Array.isArray(value)) out.push(...flatten(value, full));
+ else out.push(full);
+ }
+ return out;
+};
+
+const readNamespace = (lang, ns) => {
+ const file = path.join(localesDir, lang, `${ns}.json`);
+ if (!fs.existsSync(file)) return null;
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
+};
+
+const languages = fs.readdirSync(localesDir).filter((d) => fs.statSync(path.join(localesDir, d)).isDirectory());
+if (!languages.includes('en')) {
+ console.error('No English locale found; nothing to compare against.');
+ process.exit(1);
+}
+const namespaces = fs.readdirSync(path.join(localesDir, 'en'))
+ .filter((f) => f.endsWith('.json'))
+ .map((f) => f.replace('.json', ''));
+
+let problems = 0;
+for (const lang of languages.filter((l) => l !== 'en')) {
+ const missingAll = [];
+ const extraAll = [];
+ for (const ns of namespaces) {
+ const english = flatten(readNamespace('en', ns));
+ const translated = readNamespace(lang, ns);
+ if (translated === null) {
+ missingAll.push(`${ns}.json (whole file)`);
+ continue;
+ }
+ const theirs = new Set(flatten(translated));
+ for (const key of english) if (!theirs.has(key)) missingAll.push(`${ns}:${key}`);
+ const mine = new Set(english);
+ for (const key of theirs) if (!mine.has(key)) extraAll.push(`${ns}:${key}`);
+ }
+
+ const total = namespaces.reduce((sum, ns) => sum + flatten(readNamespace('en', ns)).length, 0);
+ const done = total - missingAll.length;
+ const pct = total === 0 ? 100 : Math.round((done / total) * 100);
+ console.log(`\n${lang}: ${done}/${total} keys (${pct}%)`);
+
+ if (missingAll.length) {
+ problems += missingAll.length;
+ console.log(` missing (${missingAll.length}):`);
+ for (const key of missingAll.slice(0, 25)) console.log(` - ${key}`);
+ if (missingAll.length > 25) console.log(` ... and ${missingAll.length - 25} more`);
+ }
+ if (extraAll.length) {
+ problems += extraAll.length;
+ console.log(` not in English — stale or misspelled (${extraAll.length}):`);
+ for (const key of extraAll.slice(0, 25)) console.log(` - ${key}`);
+ }
+ if (!missingAll.length && !extraAll.length) console.log(' complete');
+}
+
+if (problems && !reportOnly) {
+ console.error(`\n${problems} translation problem(s). Add the missing keys, or run with --report to just see the gaps.`);
+ process.exit(1);
+}
+console.log('\nTranslation check passed.');
diff --git a/client/src/components/AdminPanel.jsx b/client/src/components/AdminPanel.jsx
index 3b3d4cb..f9df031 100644
--- a/client/src/components/AdminPanel.jsx
+++ b/client/src/components/AdminPanel.jsx
@@ -102,6 +102,8 @@ import {
readLocalAutoDarkModeSettings,
readLocalVacationModeSettings,
} from '../utils/interfaceSettings.js';
+import { useTranslation } from 'react-i18next';
+import { changeLanguage, SUPPORTED_LANGUAGES } from '../i18n/index.js';
const USERS_UPDATED_EVENT = 'homeglow:users-updated';
const DEVICE_SETTINGS_UPDATED_EVENT = 'homeglow:device-settings-updated';
@@ -157,6 +159,7 @@ const buildTagUrl = (repository, tagName) => {
const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
+ const { t, i18n } = useTranslation(['admin', 'common']);
const isMobile = useIsMobile();
const [currentDeviceName, setCurrentDeviceName] = useState(() => getDeviceName());
const API_DEVICE_URL = getDeviceApiBase(API_BASE_URL);
@@ -240,6 +243,17 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
const [draggingTabNumber, setDraggingTabNumber] = useState(null);
// User display order (issue #134): drag on desktop, arrows everywhere.
const [draggingUserId, setDraggingUserId] = useState(null);
+ const handleLanguageChange = async (code) => {
+ try {
+ await changeLanguage(code);
+ setSaveMessage({ show: true, type: 'success', text: t('admin:language.saved') });
+ } catch (error) {
+ console.error('Error switching language:', error);
+ setSaveMessage({ show: true, type: 'error', text: t('admin:language.switchFailed') });
+ }
+ setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
+ };
+
const [devices, setDevices] = useState([]);
const [copyDeviceDialog, setCopyDeviceDialog] = useState({ open: false, device: null });
const [deleteDeviceDialog, setDeleteDeviceDialog] = useState({ open: false, device: null });
@@ -258,15 +272,15 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
// Refresh interval options in milliseconds
const refreshIntervalOptions = [
- { label: 'Disabled', value: 0 },
- { label: '5 minutes', value: 5 * 60 * 1000 },
- { label: '15 minutes', value: 15 * 60 * 1000 },
- { label: '30 minutes', value: 30 * 60 * 1000 },
- { label: '1 hour', value: 60 * 60 * 1000 },
- { label: '2 hours', value: 2 * 60 * 60 * 1000 },
- { label: '6 hours', value: 6 * 60 * 60 * 1000 },
- { label: '12 hours', value: 12 * 60 * 60 * 1000 },
- { label: '24 hours', value: 24 * 60 * 60 * 1000 }
+ { label: t('admin:refresh.disabled'), value: 0 },
+ { label: t('admin:refresh.min5'), value: 5 * 60 * 1000 },
+ { label: t('admin:refresh.min15'), value: 15 * 60 * 1000 },
+ { label: t('admin:refresh.min30'), value: 30 * 60 * 1000 },
+ { label: t('admin:refresh.hour1'), value: 60 * 60 * 1000 },
+ { label: t('admin:refresh.hour2'), value: 2 * 60 * 60 * 1000 },
+ { label: t('admin:refresh.hour6'), value: 6 * 60 * 60 * 1000 },
+ { label: t('admin:refresh.hour12'), value: 12 * 60 * 60 * 1000 },
+ { label: t('admin:refresh.hour24'), value: 24 * 60 * 60 * 1000 }
];
useEffect(() => {
@@ -331,7 +345,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
setPinExists(response.data.exists);
if (response.data.exists) {
- setPinModal({ open: true, mode: 'verify', title: 'Enter Admin PIN' });
+ setPinModal({ open: true, mode: 'verify', title: t('admin:pin.enter') });
} else {
setIsAuthenticated(true);
}
@@ -420,7 +434,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
await fetchPrizes();
} catch (error) {
console.error('Error adding prize to store:', error);
- alert('Failed to add the prize to the store.');
+ alert(t('admin:messages.prizeAddToStoreFailed'));
}
};
@@ -430,7 +444,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
await fetchPrizes();
} catch (error) {
console.error('Error removing prize offer:', error);
- alert('Failed to remove the offer.');
+ alert(t('admin:messages.prizeRemoveOfferFailed'));
}
};
@@ -573,13 +587,13 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
await axios.post(`${API_BASE_URL}/api/settings`, { key, value });
setSettings(prev => ({ ...prev, [key]: value }));
if (showMessage) {
- setSaveMessage({ show: true, type: 'success', text: 'Setting saved successfully!' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.settingSaved') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
}
} catch (error) {
console.error(`Error saving ${key}:`, error);
if (showMessage) {
- setSaveMessage({ show: true, type: 'error', text: 'Failed to save setting. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.settingSaveFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
}
throw error;
@@ -593,11 +607,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
key: 'daily_completion_clam_reward',
value: settings.daily_completion_clam_reward || '2',
});
- setSaveMessage({ show: true, type: 'success', text: 'Daily completion clam reward saved.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.clamRewardSaved') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} catch (error) {
console.error('Error saving clam reward:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to save clam reward. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.clamRewardFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -612,11 +626,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
axios.post(`${API_BASE_URL}/api/settings`, { key: 'CHORE_SOUND_DEFAULT', value: settings.CHORE_SOUND_DEFAULT || '' }),
axios.post(`${API_BASE_URL}/api/settings`, { key: 'CHORE_SOUND_VOLUME', value: String(settings.CHORE_SOUND_VOLUME ?? '100') }),
]);
- setSaveMessage({ show: true, type: 'success', text: 'Chore sound settings saved.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.choreSoundsSaved') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} catch (error) {
console.error('Error saving chore sound settings:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to save chore sound settings.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.choreSoundsFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -630,11 +644,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
axios.post(`${API_BASE_URL}/api/settings`, { key: 'WEATHER_API_KEY', value: settings.WEATHER_API_KEY || '' }),
axios.post(`${API_BASE_URL}/api/settings`, { key: 'PROXY_WHITELIST', value: settings.PROXY_WHITELIST || '' })
]);
- setSaveMessage({ show: true, type: 'success', text: 'All settings saved successfully!' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.allSettingsSaved') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} catch (error) {
console.error('Error saving API settings:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to save some settings. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.someSettingsFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -676,7 +690,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
const saveWidgetSettings = async () => {
const missingEnabledWidgets = getMissingEnabledCoreWidgetAssignments(widgetSettings, widgetAssignments);
if (missingEnabledWidgets.length > 0) {
- setSaveMessage({ show: true, type: 'error', text: 'Each enabled widget must have at least one tab selected.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.widgetNeedsTab') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 4000);
return;
}
@@ -697,11 +711,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
await onTabsChanged();
}
- setSaveMessage({ show: true, type: 'success', text: 'Widget settings saved successfully.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.widgetSettingsSaved') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} catch (error) {
console.error('Error saving widget settings:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to save widget settings. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.widgetSettingsFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -711,7 +725,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
const savePluginSettings = async () => {
const missingEnabledPlugins = getMissingEnabledPluginAssignments(pluginSettings, pluginAssignments, uploadedWidgets);
if (missingEnabledPlugins.length > 0) {
- setSaveMessage({ show: true, type: 'error', text: 'Each enabled plugin must have at least one tab selected.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.pluginNeedsTab') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 4000);
return;
}
@@ -767,17 +781,17 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
setSaveMessage({
show: true,
type: 'error',
- text: `Saved, but some plugin options were rejected — ${declaredSettingsErrors.join('; ')}`,
+ text: t('admin:messages.pluginOptionsRejected', { errors: declaredSettingsErrors.join('; ') }),
});
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 6000);
} else {
setPluginDeclaredDirty({});
- setSaveMessage({ show: true, type: 'success', text: 'Plugin settings saved successfully.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.pluginSettingsSaved') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
}
} catch (error) {
console.error('Error saving plugin settings:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to save plugin settings. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.pluginSettingsFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -820,7 +834,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
const saveTabDefinition = async (tabData) => {
const trimmedLabel = (tabData.label || '').trim();
if (!trimmedLabel) {
- setSaveMessage({ show: true, type: 'error', text: 'Tab label is required.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.tabLabelRequired') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
return;
}
@@ -846,11 +860,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
if (onTabsChanged) {
await onTabsChanged();
}
- setSaveMessage({ show: true, type: 'success', text: `Tab ${tabIconModalState.mode === 'edit' ? 'updated' : 'created'} successfully.` });
+ setSaveMessage({ show: true, type: 'success', text: tabIconModalState.mode === 'edit' ? t('admin:messages.tabUpdated') : t('admin:messages.tabCreated') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} catch (error) {
console.error('Error saving tab:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to save tab. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.tabSaveFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -875,11 +889,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
if (onTabsChanged) {
await onTabsChanged();
}
- setSaveMessage({ show: true, type: 'success', text: 'Tab deleted successfully.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.tabDeleted') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} catch (error) {
console.error('Error deleting tab:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to delete tab. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.tabDeleteFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -897,7 +911,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
}
} catch (error) {
console.error('Error reordering tabs:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to reorder tabs. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.tabReorderFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -918,11 +932,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
await onTabsChanged();
}
- setSaveMessage({ show: true, type: 'success', text: 'Tab label visibility updated.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.tabLabelVisibilityUpdated') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} catch (error) {
console.error('Error updating tab label visibility:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to update tab label visibility.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.tabLabelVisibilityFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -943,7 +957,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
window.dispatchEvent(new Event(USERS_UPDATED_EVENT));
} catch (error) {
console.error('Error reordering users:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to reorder users. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.userReorderFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -1052,11 +1066,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
await onTabsChanged();
}
- setSaveMessage({ show: true, type: 'success', text: 'Device tabs and widget settings copied successfully.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.deviceCopied') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} catch (error) {
console.error('Error copying device settings:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to copy device settings. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.deviceCopyFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -1098,7 +1112,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
setCurrentDeviceName(nextName);
setRenameDeviceDialog({ open: false, currentName: '', newName: '', error: '' });
await fetchDevices();
- setSaveMessage({ show: true, type: 'success', text: 'Device Name updated. Reloading to apply changes.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.deviceRenamed') });
setTimeout(() => {
window.location.reload();
}, 400);
@@ -1118,7 +1132,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
}
if (deviceName === currentDeviceName) {
- setSaveMessage({ show: true, type: 'error', text: 'You cannot delete the current device.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.cannotDeleteCurrentDevice') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
setDeleteDeviceDialog({ open: false, device: null });
return;
@@ -1129,11 +1143,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
await axios.delete(`${API_BASE_URL}/api/devices/${encodeURIComponent(deviceName)}`);
setDeleteDeviceDialog({ open: false, device: null });
await fetchDevices();
- setSaveMessage({ show: true, type: 'success', text: 'Device deleted successfully.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.deviceDeleted') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} catch (error) {
console.error('Error deleting device:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to delete device. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.deviceDeleteFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -1150,11 +1164,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
applyAccentColors();
window.dispatchEvent(new Event(INTERFACE_SETTINGS_UPDATED_EVENT));
- setSaveMessage({ show: true, type: 'success', text: 'Accent colors saved for this display.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.colorsSaved') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} catch (error) {
console.error('Error saving accent colors:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to save accent colors. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.colorsFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -1176,7 +1190,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
const resetToDefaults = () => {
setInterfaceColors({ ...DEFAULT_INTERFACE_COLORS });
- setSaveMessage({ show: true, type: 'info', text: 'Reset to default colors. Click Save to apply.' });
+ setSaveMessage({ show: true, type: 'info', text: t('admin:messages.colorsReset') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
};
@@ -1186,11 +1200,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
const normalizedScreensaver = normalizeScreensaverSettings(screensaverSettings);
localStorage.setItem(SCREENSAVER_SETTINGS_STORAGE_KEY, JSON.stringify(normalizedScreensaver));
window.dispatchEvent(new Event(INTERFACE_SETTINGS_UPDATED_EVENT));
- setSaveMessage({ show: true, type: 'success', text: 'Screensaver settings saved for this display.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.screensaverSaved') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} catch (error) {
console.error('Error saving screensaver settings:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to save screensaver settings. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.screensaverFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -1215,11 +1229,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
} catch (serverError) {
console.warn('Vacation mode saved for this display, but the household setting could not be updated:', serverError);
}
- setSaveMessage({ show: true, type: 'success', text: 'Vacation mode settings saved for this display.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.vacationSaved') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} catch (error) {
console.error('Error saving vacation mode settings:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to save vacation mode settings. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.vacationFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -1292,7 +1306,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
localStorage.setItem(AUTO_DARK_MODE_SETTINGS_STORAGE_KEY, JSON.stringify(nextSettings));
window.dispatchEvent(new Event(INTERFACE_SETTINGS_UPDATED_EVENT));
setAutoDarkModeSettings(nextSettings);
- setSaveMessage({ show: true, type: 'success', text: 'Auto dark mode disabled for this display.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.autoDarkDisabled') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
return;
}
@@ -1301,7 +1315,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
setSaveMessage({
show: true,
type: 'error',
- text: 'Add and save your OpenWeather API key in Connections before enabling auto dark mode.',
+ text: t('admin:messages.autoDarkNeedsKey'),
});
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3500);
return;
@@ -1311,7 +1325,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
setSaveMessage({
show: true,
type: 'error',
- text: 'Enter a location before enabling auto dark mode.',
+ text: t('admin:messages.autoDarkNeedsLocation'),
});
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
return;
@@ -1335,7 +1349,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
setSaveMessage({
show: true,
type: 'success',
- text: 'Auto dark mode saved for this display. Use the bottom-bar theme button until the half sun/half moon icon appears.',
+ text: t('admin:messages.autoDarkSaved'),
});
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 4500);
} catch (error) {
@@ -1496,7 +1510,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
setDeleteUserDialog({ open: false, user: null });
} catch (error) {
console.error('Error deleting user:', error);
- alert('Failed to delete user. Please try again.');
+ alert(t('admin:messages.userDeleteFailed'));
} finally {
setIsLoading(false);
}
@@ -1552,7 +1566,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
};
const deletePrize = async (prizeId) => {
- if (window.confirm('Are you sure you want to delete this prize?')) {
+ if (window.confirm(t('admin:confirm.deletePrize'))) {
try {
setIsLoading(true);
await axios.delete(`${API_BASE_URL}/api/prizes/${prizeId}`);
@@ -1581,20 +1595,20 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
if (onPluginsChanged) onPluginsChanged();
} catch (error) {
console.error('Error uploading widget:', error);
- alert('Failed to upload widget. Please try again.');
+ alert(t('admin:messages.widgetUploadFailed'));
} finally {
setIsLoading(false);
}
};
const deleteWidget = async (filename) => {
- if (window.confirm('Are you sure you want to delete this widget?')) {
+ if (window.confirm(t('admin:confirm.deleteWidget'))) {
// Platform plugins own server-side storage/settings. Keeping them lets a
// reinstall of the SAME plugin resume; purging prevents a different
// plugin that claims the same id from inheriting the data.
const widgetEntry = uploadedWidgets.find((widget) => widget.filename === filename);
const purgeData = Boolean(widgetEntry?.pluginId) && window.confirm(
- "Also delete this plugin's stored data and settings?\n\nChoose Cancel to keep them (e.g. if you plan to reinstall the same plugin later)."
+ t('admin:confirm.purgePluginData')
);
try {
setIsLoading(true);
@@ -1634,10 +1648,10 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
});
fetchUploadedWidgets();
if (onPluginsChanged) onPluginsChanged();
- alert(`Widget "${widget.name}" installed successfully!`);
+ alert(t('admin:messages.widgetInstalled', { name: widget.name }));
} catch (error) {
console.error('Error installing GitHub widget:', error);
- alert('Failed to install widget. Please try again.');
+ alert(t('admin:messages.widgetInstallFailed'));
} finally {
setIsLoading(false);
}
@@ -1653,7 +1667,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
};
const deleteChore = async (scheduleId) => {
- if (window.confirm('Are you sure you want to remove this chore schedule?')) {
+ if (window.confirm(t('admin:confirm.deleteSchedule'))) {
try {
setIsLoading(true);
await axios.delete(`${API_BASE_URL}/api/chore-schedules/${scheduleId}`);
@@ -1666,7 +1680,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
}
} catch (error) {
console.error('Error deleting chore schedule:', error);
- alert('Failed to delete chore schedule. Please try again.');
+ alert(t('admin:messages.scheduleDeleteFailed'));
} finally {
setIsLoading(false);
}
@@ -1687,7 +1701,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
try {
setIsLoading(true);
- console.log(`Uploading picture for user ${userId}...`);
+ console.log(t('admin:messages.uploadingPicture'));
const response = await axios.post(
`${API_BASE_URL}/api/users/${userId}/upload-picture`,
@@ -1702,7 +1716,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
} catch (error) {
console.error('Error uploading profile picture:', error);
console.error('Error details:', error.response?.data);
- alert('Failed to upload profile picture. Please try again.');
+ alert(t('admin:messages.pictureUploadFailed'));
} finally {
setIsLoading(false);
event.target.value = '';
@@ -1786,7 +1800,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
setPinExists(true);
setIsAuthenticated(true);
setPinModal({ open: false, mode: 'verify', title: '' });
- setSaveMessage({ show: true, type: 'success', text: 'Admin PIN set successfully!' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.pinSet') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} else {
const response = await axios.post(`${API_BASE_URL}/api/admin-pin/verify`, { pin });
@@ -1813,22 +1827,22 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
};
const handleUpdatePin = () => {
- setPinModal({ open: true, mode: 'set', title: 'Update Admin PIN' });
+ setPinModal({ open: true, mode: 'set', title: t('admin:pin.update') });
};
const handleClearPin = async () => {
- if (!window.confirm('Are you sure you want to remove the admin PIN? Anyone will be able to access the admin panel without a PIN.')) {
+ if (!window.confirm(t('admin:confirm.removePin'))) {
return;
}
try {
setIsLoading(true);
await axios.delete(`${API_BASE_URL}/api/admin-pin`);
setPinExists(false);
- setSaveMessage({ show: true, type: 'success', text: 'Admin PIN removed. Admin panel is now unprotected.' });
+ setSaveMessage({ show: true, type: 'success', text: t('admin:messages.pinRemoved') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 4000);
} catch (error) {
console.error('Error clearing PIN:', error);
- setSaveMessage({ show: true, type: 'error', text: 'Failed to remove PIN. Please try again.' });
+ setSaveMessage({ show: true, type: 'error', text: t('admin:messages.pinRemoveFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
} finally {
setIsLoading(false);
@@ -1883,7 +1897,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
const getRefreshIntervalLabel = (interval) => {
const option = refreshIntervalOptions.find(opt => opt.value === interval);
- return option ? option.label : 'Disabled';
+ return option ? option.label : t('admin:refresh.disabled');
};
const adminTabs = [
@@ -1962,10 +1976,10 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
scrollButtons="auto"
allowScrollButtonsMobile
>
-
-
-
-
+
+
+
+
@@ -1985,7 +1999,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
}}
>
- Enable widgets to show them on the dashboard. Click to select a widget, then drag to move or resize from corners.
+ {t('admin:widgets.help')}
{Object.entries(widgetSettings).filter(([key]) =>
@@ -2008,7 +2022,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
onChange={() => handleWidgetToggle(widget, 'enabled')}
/>
}
- label="Enabled"
+ label={t('common:labels.enabled')}
/>
@@ -2034,10 +2048,10 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
renderInput={(params) => (
{
- Weather Widget
+ {t('admin:widgets.weatherWidget')}
@@ -2071,7 +2085,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
onChange={() => handleWidgetToggle('weather', 'enabled')}
/>
}
- label="Enabled"
+ label={t('common:labels.enabled')}
/>
@@ -2097,10 +2111,10 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
renderInput={(params) => (
{
}>
- Save Widget Settings
+ {t('admin:widgets.saveSettings')}
)}
@@ -2128,7 +2142,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
<>
- Upload Custom Widget
+ {t('admin:widgets.uploadCustom')}
- Uploaded Widgets
+ {t('admin:widgets.uploaded')}
{uploadedWidgets.map((widget) => (
deleteWidget(widget.filename)} color="error">
@@ -2165,7 +2179,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
- GitHub Widget Repository
+ {t('admin:widgets.githubRepo')}
: }
@@ -2173,7 +2187,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
variant="contained"
color="primary"
>
- REFRESH AVAILABLE PLUGINS
+ {t('admin:widgets.refreshAvailable')}
@@ -2192,7 +2206,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{githubWidgets.length === 0 && !loadingGithub && (
}>
- Click the "REFRESH AVAILABLE PLUGINS" button above to load the list of plugins available for installation.
+ {t('admin:widgets.clickThe')} {t('admin:widgets.refreshAvailable')} {t('admin:widgets.buttonAboveToLoad')}
)}
@@ -2210,7 +2224,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
size="small"
variant="outlined"
>
- Install
+ {t('admin:widgets.install')}
@@ -2229,9 +2243,9 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
}}
>
- Plugin Settings
+ {t('admin:plugins.settings')}
- Configure each installed plugin below. Enable them, set transparency, refresh intervals, and assign to tabs just like core widgets.
+ {t('admin:plugins.settingsHelp')}
{uploadedWidgets.map((plugin) => {
@@ -2268,7 +2282,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
}}
/>
}
- label="Enabled"
+ label={t('common:labels.enabled')}
/>
{
}}
/>
}
- label="Transparent Background"
+ label={t('admin:plugins.transparentBackground')}
sx={{ ml: 2 }}
/>
@@ -2304,7 +2318,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{plugin.pluginId && Array.isArray(plugin.manifest?.settings) && plugin.manifest.settings.length > 0 && (
- Plugin Options
+ {t('admin:plugins.options')}
{plugin.manifest.settings.map((setting) => {
const declaredValues = pluginDeclaredValues[plugin.pluginId] || {};
@@ -2371,7 +2385,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
)}
{setting.scope === 'device' && (
- Per device — applies to the device you're editing from
+ {t('admin:plugins.perDevice')}
)}
@@ -2396,10 +2410,10 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
renderInput={(params) => (
{
})}
}>
- Save Plugin Settings
+ {t('admin:plugins.saveSettings')}
)}
@@ -2425,10 +2439,10 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
<>
- Manage dashboard tabs. Reorder with the arrows, or drag rows on a desktop browser. Home tab cannot be edited or deleted.
+ {t('admin:tabs.manageHelp')}
} onClick={openCreateTabDialog}>
- Add Tab
+ {t('admin:tabs.addTab')}
@@ -2436,11 +2450,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
- Order
- Label
- Icon
- Show Label
- Actions
+ {t('admin:tabs.order')}
+ {t('admin:tabs.label')}
+ {t('admin:tabs.icon')}
+ {t('admin:tabs.showLabel')}
+ {t('common:labels.actions')}
@@ -2468,7 +2482,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
opacity: draggingTabNumber === tab.number ? 0.65 : 1,
}}
>
-
+
{!isHome && (
@@ -2476,11 +2490,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{!isHome && (
<>
-
+
moveTab(tab.number, -1)}
>
@@ -2488,11 +2502,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
-
+
= lastOrderIndex}
onClick={() => moveTab(tab.number, 1)}
>
@@ -2504,22 +2518,22 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
)}
-
+
{tab.label}
{isHome && (
-
+
)}
-
+
-
+
e.stopPropagation()}
onChange={() => toggleTabShowLabel(tab)}
disabled={isLoading}
- slotProps={{ input: { 'aria-label': `Toggle show label for ${tab.label}` } }}
+ slotProps={{ input: { 'aria-label': t('admin:tabs.toggleShowLabel', { name: tab.label }) } }}
/>
@@ -2552,15 +2566,15 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{widgetsSubTab === 3 && (
<>
- Manage devices and copy tabs/widget settings between them. Copying will overwrite the current device tabs and widget assignments.
+ {t('admin:devices.manageHelp')}
- Current Device Name
+ {t('admin:devices.currentName')}
-
+
{currentDeviceName}
@@ -2571,10 +2585,10 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
- Name
- Last Updated
- Widgets
- Actions
+ {t('common:labels.name')}
+ {t('admin:devices.lastUpdated')}
+ {t('admin:subTabs.widgets')}
+ {t('common:labels.actions')}
@@ -2582,18 +2596,18 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
const isCurrent = device.name === currentDeviceName;
return (
-
+
- {isCurrent && }
+ {isCurrent && }
{device.name}
-
+
{device.updateTime ? new Date(device.updateTime).toLocaleString() : 'Unknown'}
-
+
@@ -2602,7 +2616,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
onClick={openRenameDeviceDialog}
color="primary"
size="small"
- title="Rename current device"
+ title={t('admin:devices.renameCurrent')}
>
@@ -2611,7 +2625,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
onClick={() => openCopyDeviceDialog(device)}
color="primary"
size="small"
- title="Copy this device to current"
+ title={t('admin:devices.copyToCurrent')}
>
@@ -2620,7 +2634,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
onClick={() => openDeleteDeviceDialog(device)}
color="error"
size="small"
- title="Delete device"
+ title={t('admin:devices.deleteDevice')}
disabled={isCurrent}
>
@@ -2642,15 +2656,46 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{activeTab === 1 && (
+ {/* Language (issue #137), per display like the other interface
+ settings. Week start deliberately lives in the calendar
+ widget's own settings, which has had per-tab week/month start
+ controls since #127 — a second global control would fight it. */}
+
+
+
+
+ {t('common:language.label')}
+
+
+ {t('common:language.helper')}
+
+
+
+
+
+
- Accent Colors
+ {t('admin:colors.heading')}
}
onClick={resetToDefaults}
size="small"
>
- Reset to Defaults
+ {t('admin:colors.resetDefaults')}
@@ -2661,7 +2706,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
)}
- Background color applies to light mode only. Accent color is used throughout the dashboard for highlights and interactive elements.
+ {t('admin:colors.help')}
@@ -2677,7 +2722,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
startIcon={}
size="large"
>
- Save Accent Colors
+ {t('admin:colors.save')}
@@ -2693,11 +2738,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
- Screensaver
+ {t('admin:screensaver.heading')}
- The screensaver activates after a period of inactivity, cycling through tabs or displaying a photo slideshow.
+ {t('admin:screensaver.help')}
@@ -2708,12 +2753,12 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
onChange={(e) => setScreensaverSettings(prev => ({ ...prev, enabled: e.target.checked }))}
/>
}
- label="Enable Screensaver"
+ label={t('admin:screensaver.enable')}
sx={{ mb: 3 }}
/>
- Screensaver Mode
+ {t('admin:screensaver.mode')}
{
color: !hasTabsCreated ? 'text.disabled' : 'inherit'
}}
>
- Cycle Through Tabs
+ {t('admin:screensaver.cycleTabs')}
{hasTabsCreated
- ? `Automatically switch between ${tabs.length} tab${tabs.length !== 1 ? 's' : ''}`
+ ? t('admin:screensaver.cycleTabsHelp', { count: tabs.length })
: 'No tabs created yet'}
{!hasTabsCreated && (
-
+
)}
@@ -2779,7 +2824,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
color: !hasImmichConfigured ? 'text.disabled' : 'inherit'
}}
>
- Immich Photo Slideshow
+ {t('admin:screensaver.immichSlideshow')}
{
{!hasImmichConfigured && (
-
+
)}
@@ -2822,7 +2867,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{screensaverSettings.mode === 'photos' && (
setScreensaverSettings(prev => ({ ...prev, slideshowInterval: value }))}
min={3}
@@ -2838,7 +2883,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{screensaverSettings.mode === 'tabs' && (
setScreensaverSettings(prev => ({ ...prev, slideshowInterval: value }))}
min={5}
@@ -2859,18 +2904,18 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
fullWidth
sx={{ mt: 2 }}
>
- Save Screensaver Settings
+ {t('admin:screensaver.save')}
- Vacation Mode
+ {t('admin:vacation.heading')}
- While on vacation, chore due-time chimes are muted and the screensaver becomes a
+ {t('admin:vacation.help')}
fun vacation animation. Settings apply to this display and persist until you turn
vacation mode off.
@@ -2882,7 +2927,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
onChange={(e) => setVacationModeSettings(prev => ({ ...prev, enabled: e.target.checked }))}
/>
}
- label="Enable Vacation Mode"
+ label={t('admin:vacation.enable')}
sx={{ mb: 1, display: 'block' }}
/>
@@ -2894,7 +2939,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
onChange={(e) => setVacationModeSettings(prev => ({ ...prev, muteSounds: e.target.checked }))}
/>
}
- label="Mute chore sounds while on vacation"
+ label={t('admin:vacation.muteSounds')}
sx={{ mb: 1, display: 'block' }}
/>
@@ -2903,7 +2948,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
setVacationModeSettings(prev => ({ ...prev, startDate: e.target.value }))}
slotProps={{ inputLabel: { shrink: true } }}
@@ -2912,7 +2957,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
setVacationModeSettings(prev => ({ ...prev, endDate: e.target.value }))}
slotProps={{ inputLabel: { shrink: true } }}
@@ -2922,7 +2967,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
)}
{vacationModeSettings.enabled && (
- With dates set, vacation starts and ends automatically, missed-chore tracking pauses
+ {t('admin:vacation.dateHelp')}
for those days, and streaks bridge across them permanently. Without dates, vacation
stays on until you turn it off.
@@ -2935,23 +2980,23 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
fullWidth
sx={{ mt: 2 }}
>
- Save Vacation Mode Settings
+ {t('admin:vacation.save')}
- Daylight Auto Dark Mode
+ {t('admin:autoDark.heading')}
- To enable auto mode: save an OpenWeather API key in Connections, enter a location here, save this section, then press the bottom-bar theme button until the half sun/half moon icon appears.
+ {t('admin:autoDark.help')}
{!settings.WEATHER_API_KEY?.trim() && (
- OpenWeather API key is not set yet. Add it in the Connections tab first.
+ {t('admin:autoDark.noApiKey')}
)}
@@ -2967,13 +3012,13 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
}}
/>
}
- label="Enable Daylight Auto Dark Mode"
+ label={t('admin:autoDark.enable')}
sx={{ mb: 2 }}
/>
{
const nextValue = e.target.value;
@@ -2982,7 +3027,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
locationQuery: nextValue,
}));
}}
- helperText="Examples: Dallas,TX,US, London,UK, or ZIP code like 76034"
+ helperText={t('admin:autoDark.locationHelp')}
sx={{ mb: 2 }}
/>
@@ -2994,7 +3039,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{autoDarkModeSunTimesLoading && (
- Loading today's sunrise and sunset...
+ {t('admin:autoDark.loadingSun')}
)}
{!autoDarkModeSunTimesLoading && autoDarkModeSunTimes.sunrise && autoDarkModeSunTimes.sunset && (
@@ -3029,7 +3074,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{activeTab === 2 && (
-
+
{
@@ -3041,7 +3086,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
setNewUser({ ...newUser, username: e.target.value })}
/>
@@ -3049,7 +3094,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
setNewUser({ ...newUser, email: e.target.value })}
@@ -3063,7 +3108,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
fullWidth
sx={{ height: '56px' }}
>
- Add User
+ {t('admin:users.addUser')}
@@ -3071,17 +3116,17 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{newUser.profile_picture ? (
) : (
?
)}
- Optional — pick from the built-in set, or upload a photo after adding.
+ {t('admin:users.avatarHelp')}
@@ -3093,13 +3138,13 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
- Order
- Avatar
- Username
- Email
- Clam Total
- Chores
- Actions
+ {t('admin:tabs.order')}
+ {t('admin:users.avatar')}
+ {t('admin:users.username')}
+ {t('admin:users.email')}
+ {t('admin:users.clamTotal')}
+ {t('admin:users.chores')}
+ {t('common:labels.actions')}
@@ -3125,9 +3170,9 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
opacity: draggingUserId === user.id ? 0.65 : 1,
}}
>
-
+
{isBonus ? (
-
+
) : (
@@ -3135,11 +3180,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
on a disabled button; the aria-label has to go
on the button itself, since the wrapper would
otherwise swallow the accessible name. */}
-
+
moveUser(user.id, -1)}
>
@@ -3147,11 +3192,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
-
+
= reorderableUsers.length - 1}
onClick={() => moveUser(user.id, 1)}
>
@@ -3170,7 +3215,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
size="small"
variant="outlined"
>
- Upload
+ {t('common:actions.upload')}
{
onChange={(e) => handleProfilePictureUpload(user.id, e)}
/>
-
+
-
+
{editingUser?.id === user.id ? (
{
user.username
)}
-
+
{editingUser?.id === user.id ? (
{
user.email
)}
-
+
-
+
setClamModalUser(user)}
color="primary"
@@ -3229,7 +3274,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
-
+
-
+
{
onChange={(e) => setNewPrize({ ...newPrize, repeatable: e.target.checked })}
/>
}
- label="Repeatable (can be redeemed more than once)"
+ label={t('admin:prizes.repeatableLabel')}
/>
@@ -3459,19 +3504,19 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{editingPrize?.id === prize.id ? (
setEditingPrize({ ...editingPrize, name: e.target.value })}
sx={{ flex: 1 }}
/>
setEditingPrize({ ...editingPrize, clam_cost: parseInt(e.target.value) || 0 })}
sx={{ width: { xs: '100%', sm: 120 } }}
/>
-
+
{
onChange={(e) => setEditingPrize({ ...editingPrize, repeatable: e.target.checked })}
/>
}
- label="Repeatable"
+ label={t('admin:prizes.repeatableShort')}
sx={{ mr: 0 }}
/>
@@ -3494,7 +3539,9 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
<>
@@ -3516,12 +3563,12 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{prizeOffers.length === 0 ? (
- The store is empty. Use ➕ next to a prize above to stock it.
+ {t('admin:prizes.storeEmpty')}
) : (
@@ -3531,7 +3578,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
primary={`${offer.name} — ${offer.clam_cost} 🥟${offer.repeatable ? ' · 🔁' : ''}`}
secondary={
offer.status === 'requested'
- ? `Requested by ${offer.requested_by_name || 'unknown'} — approve or decline on the dashboard`
+ ? t('admin:prizes.requestedBy', { name: offer.requested_by_name || t('common:state.none') })
: 'On the shelf'
}
/>
@@ -3555,7 +3602,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{activeTab === 5 && (
- Security Settings
+ {t('admin:security.heading')}
{saveMessage.show && (
@@ -3564,13 +3611,13 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
)}
- The admin PIN is required to access the admin panel. Keep your PIN secure and memorable.
+ {t('admin:security.help')}
- Admin PIN Protection
+ {t('admin:security.pinProtection')}
@@ -3583,7 +3630,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
- PIN Requirements:
+ {t('admin:security.requirements')}
• 4-8 numeric digits
@@ -3600,7 +3647,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
- Current Status:
+ {t('admin:security.currentStatus')}
{
fontSize: '1rem'
}}
>
- {pinExists ? 'Update Admin PIN' : 'Set Admin PIN'}
+ {pinExists ? t('admin:pin.update') : t('admin:pin.setPin')}
{pinExists && (
)}
{pinExists && (
- Changing your PIN will require you to use the new PIN on your next admin panel access.
+ {t('admin:security.changeNote')}
)}
@@ -3658,7 +3705,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{activeTab === 6 && (
- Connections
+ {t('admin:connections.heading')}
{saveMessage.show && (
@@ -3668,7 +3715,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
- API Keys
+ {t('admin:connections.apiKeys')}
{
>
setSettings(prev => ({ ...prev, WEATHER_API_KEY: e.target.value }))}
sx={{ mb: 2 }}
- helperText="Get your free API key from openweathermap.org/api"
+ helperText={t('admin:connections.openWeatherHelp')}
/>
setSettings(prev => ({ ...prev, PROXY_WHITELIST: e.target.value }))}
sx={{ mb: 2 }}
- helperText="Domains allowed for proxy requests (e.g., api.example.com, another-api.com)"
+ helperText={t('admin:connections.proxyHelp')}
/>
@@ -3726,7 +3773,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
- About
+ {t('admin:about.heading')}
}
@@ -3738,7 +3785,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
- This tab shows build metadata for the running client and server plus any Git tags that point to each commit.
+ {t('admin:about.help')}
{aboutError && (
@@ -3750,7 +3797,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{
{
open={tabIconModalState.open}
onClose={closeTabEditorDialog}
onSave={saveTabDefinition}
- title={tabIconModalState.mode === 'edit' ? 'Edit Tab' : 'Create New Tab'}
- saveButtonText={tabIconModalState.mode === 'edit' ? 'Save Changes' : 'Create Tab'}
+ title={tabIconModalState.mode === 'edit' ? t('admin:tabs.editTitle') : t('admin:tabs.createTitle')}
+ saveButtonText={tabIconModalState.mode === 'edit' ? t('admin:tabs.saveChanges') : t('admin:tabs.createButton')}
initialData={tabIconModalState.initialData}
/>
@@ -3792,7 +3839,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
open={deleteTabDialog.open}
onClose={() => setDeleteTabDialog({ open: false, tab: null })}
onConfirm={confirmDeleteTab}
- title="Delete Tab"
+ title={t('admin:tabs.deleteTab')}
itemName={deleteTabDialog.tab?.label}
itemLabel="Tab"
warningMessage="Widgets assigned to this tab will be moved by the server rules for deleted tabs."
@@ -3808,29 +3855,29 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
- Copy Device Settings
+ {t('admin:devices.copyTitle')}
- This action will COPY all tabs and widget settings from the selected device to this current client.
+ {t('admin:devices.copyExplain')}
- This will overwrite all current tabs and widget assignments.
+ {t('admin:devices.copyWarning')}
- Source device: {copyDeviceDialog.device?.name}
+ {t('admin:devices.sourceDevice')} {copyDeviceDialog.device?.name}
- Destination device: {currentDeviceName}
+ {t('admin:devices.destinationDevice')} {currentDeviceName}
}>
- Confirm Copy
+ {t('admin:devices.confirmCopy')}
@@ -3845,13 +3892,13 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
- Rename Current Device Name
+ {t('admin:devices.renameTitle')}
setRenameDeviceDialog(prev => ({ ...prev, newName: e.target.value, error: '' }))}
error={Boolean(renameDeviceDialog.error)}
@@ -3862,10 +3909,10 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
}>
- Save Name
+ {t('admin:devices.saveName')}
@@ -3874,7 +3921,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
open={deleteDeviceDialog.open}
onClose={() => setDeleteDeviceDialog({ open: false, device: null })}
onConfirm={confirmDeleteDevice}
- title="Delete Device"
+ title={t('admin:devices.deleteDevice')}
itemName={deleteDeviceDialog.device?.name}
itemLabel="Device"
warningMessage="This action cannot be undone."
@@ -3885,7 +3932,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
open={deleteUserDialog.open}
onClose={() => setDeleteUserDialog({ open: false, user: null })}
onConfirm={() => deleteUser(deleteUserDialog.user?.id)}
- title="Delete User"
+ title={t('admin:users.deleteUser')}
itemName={deleteUserDialog.user?.username}
itemLabel="User"
warningMessage="This action cannot be undone!"
@@ -3919,47 +3966,47 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
{choreModal.userChores.length === 0 ? (
- No chores assigned to this user.
+ {t('admin:chores.noChoresForUser')}
) : (
- Title
- Description
- Schedule (Crontab)
- Visible
- Clams
- Actions
+ {t('common:labels.title')}
+ {t('common:labels.description')}
+ {t('admin:chores.scheduleCrontab')}
+ {t('admin:chores.visible')}
+ {t('admin:chores.clams')}
+ {t('common:labels.actions')}
{choreModal.userChores.map((chore) => (
-
+
{chore.title}
-
+
{chore.description || 'No description'}
-
+
{chore.crontab || 'One-time'}
-
+
-
+
{chore.clam_value > 0 ? (
{
/>
) : (
- Regular
+ {t('admin:chores.regular')}
)}
@@ -3977,7 +4024,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
onClick={() => deleteChore(chore.id)}
color="error"
size="small"
- title="Delete chore"
+ title={t('admin:chores.deleteChore')}
>
@@ -3991,7 +4038,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
@@ -4023,11 +4070,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => {
maxWidth="sm"
fullWidth
>
- Choose an avatar
+ {t('admin:users.chooseAnAvatar')}
{defaultAvatars.length === 0 ? (
- No built-in avatars available.
+ {t('admin:users.noAvatars')}
) : (
{
)}
-
+
diff --git a/client/src/components/CalendarWidget.jsx b/client/src/components/CalendarWidget.jsx
index 442c8b0..4a588f2 100644
--- a/client/src/components/CalendarWidget.jsx
+++ b/client/src/components/CalendarWidget.jsx
@@ -1,31 +1,34 @@
import React, { useState, useEffect, useRef } from 'react';
import { Card, Typography, Box, List, ListItem, ListItemText, Dialog, DialogTitle, DialogContent, DialogActions, Button, IconButton, Popover, ToggleButton, ToggleButtonGroup, TextField, Switch, Checkbox, FormControlLabel, Select, MenuItem, FormControl, InputLabel, Chip, Divider, CircularProgress, Alert, Tooltip } from '@mui/material';
import { Settings, ViewModule, ViewWeek, ChevronLeft, ChevronRight, Add, Delete, Edit, Refresh, Remove, Sync, Schedule } from '@mui/icons-material';
-import { Calendar, momentLocalizer } from 'react-big-calendar';
import moment from 'moment';
import { SketchPicker } from 'react-color';
import axios from 'axios';
+import { useTranslation } from 'react-i18next';
import { API_BASE_URL } from '../utils/apiConfig.js';
import { getDeviceApiBase } from '../utils/deviceName.js';
import { getEventPillPalette, getPreferredColorMode } from '../utils/colorContrast.js';
import { buildMergedDotColors, buildMergedDotBackground, describeMergedCalendars } from '../utils/calendarMergeColors.js';
import useIsMobile from '../hooks/useIsMobile.js';
-import 'react-big-calendar/lib/css/react-big-calendar.css';
import MonthDayCell from './MonthDayCell.jsx';
import ColorPickerPopover from './ColorPickerPopover.jsx';
-
-const localizer = momentLocalizer(moment);
-
-const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
-const WEEKDAY_OPTIONS = [
- { label: 'Sunday', value: 'sunday' },
- { label: 'Monday', value: 'monday' },
- { label: 'Tuesday', value: 'tuesday' },
- { label: 'Wednesday', value: 'wednesday' },
- { label: 'Thursday', value: 'thursday' },
- { label: 'Friday', value: 'friday' },
- { label: 'Saturday', value: 'saturday' },
-];
+import {
+ formatTime,
+ formatShortDate,
+ formatShortDateWithYear,
+ formatShortDateTime,
+ formatFullDate,
+ formatMonthYear,
+ formatWeekdayShort,
+ formatMonthShort,
+ formatDayOfMonth,
+ getWeekdayLabels,
+ getWeekdayOptions,
+} from '../utils/dateUtils.js';
+
+// Stored values for the week/month start settings. Display names come from
+// getWeekdayOptions() so they localize; these keys never change.
+const WEEKDAY_VALUES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
const WEEKDAY_INDEX = {
sunday: 0,
monday: 1,
@@ -113,8 +116,8 @@ const packEventsIntoLanes = (events) => {
};
const TAB_CALENDAR_VIEW_MODES = new Set(['month', 'week']);
-const VALID_WEEK_VIEW_STARTS = new Set(['today', 'yesterday', ...WEEKDAY_OPTIONS.map((option) => option.value)]);
-const VALID_MONTH_VIEW_STARTS = new Set(['today', 'yesterday', 'first-day-of-month', ...WEEKDAY_OPTIONS.map((option) => option.value)]);
+const VALID_WEEK_VIEW_STARTS = new Set(['today', 'yesterday', ...WEEKDAY_VALUES]);
+const VALID_MONTH_VIEW_STARTS = new Set(['today', 'yesterday', 'first-day-of-month', ...WEEKDAY_VALUES]);
const parseTabConfigJson = (configJson) => {
if (!configJson) return {};
@@ -188,6 +191,7 @@ const CalendarWidget = ({
activeTab = 1,
activeTabConfigJson = null,
}) => {
+ const { t } = useTranslation(['calendar', 'common']);
const API_DEVICE_URL = getDeviceApiBase(API_BASE_URL);
// On phones the week view reads best (issue #118), so it is the default
// whenever the tab has no explicit view override. The month/week toggle
@@ -257,15 +261,15 @@ const CalendarWidget = ({
};
const syncIntervalOptions = [
- { label: 'Disabled', value: 0 },
- { label: '5 minutes', value: 5 },
- { label: '15 minutes', value: 15 },
- { label: '30 minutes', value: 30 },
- { label: '1 hour', value: 60 },
- { label: '2 hours', value: 120 },
- { label: '6 hours', value: 360 },
- { label: '12 hours', value: 720 },
- { label: '24 hours', value: 1440 }
+ { label: t('calendar:refresh.disabled'), value: 0 },
+ { label: t('calendar:refresh.min5'), value: 5 },
+ { label: t('calendar:refresh.min15'), value: 15 },
+ { label: t('calendar:refresh.min30'), value: 30 },
+ { label: t('calendar:refresh.hour1'), value: 60 },
+ { label: t('calendar:refresh.hour2'), value: 120 },
+ { label: t('calendar:refresh.hour6'), value: 360 },
+ { label: t('calendar:refresh.hour12'), value: 720 },
+ { label: t('calendar:refresh.hour24'), value: 1440 }
];
// Initial data fetch
@@ -530,7 +534,7 @@ const CalendarWidget = ({
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
- return moment(date).format('MMM D, h:mm A');
+ return formatShortDateTime(date);
};
const fetchCalendarEvents = async () => {
@@ -684,7 +688,7 @@ const CalendarWidget = ({
};
const handleDeleteCalendar = async (calendarId) => {
- if (window.confirm('Are you sure you want to delete this calendar?')) {
+ if (window.confirm(t('calendar:sources.confirmDelete'))) {
try {
await axios.delete(`${API_BASE_URL}/api/calendar-sources/${calendarId}`);
await fetchCalendarSources();
@@ -707,7 +711,7 @@ const CalendarWidget = ({
setTestingConnection(false);
}
} else {
- setTestResult({ success: false, message: 'Please save the calendar before testing' });
+ setTestResult({ success: false, message: t('calendar:sources.saveBeforeTesting') });
}
};
@@ -787,7 +791,7 @@ const CalendarWidget = ({
};
const formatEventTime = (date) => {
- return moment(date).format('MMM D, h:mm A');
+ return formatShortDateTime(date);
};
const getGoogleSources = () => calendarSources.filter((s) => s.type === 'Google' && s.enabled);
@@ -883,7 +887,7 @@ const CalendarWidget = ({
const deleteEvent = async (event) => {
if (!isGoogleEvent(event)) return;
- if (!window.confirm('Delete this event from Google Calendar?')) return;
+ if (!window.confirm(t('calendar:event.confirmDelete'))) return;
try {
await axios.delete(
`${API_BASE_URL}/api/calendar-sources/${event.source_id}/events/${encodeURIComponent(event.id)}`,
@@ -1034,9 +1038,9 @@ const CalendarWidget = ({
return {
date,
- dayName: moment(date).format('ddd'),
- dayNumber: moment(date).format('D'),
- monthName: moment(date).format('MMM'),
+ dayName: formatWeekdayShort(date),
+ dayNumber: formatDayOfMonth(date),
+ monthName: formatMonthShort(date),
isToday: moment(date).isSame(moment(new Date()), 'day'),
multiDaySlottedRows,
multiDaySlotCount,
@@ -1111,15 +1115,12 @@ const CalendarWidget = ({
};
const formatDateRangeLabel = (start, end) => {
+ // Both endpoints go through the date seam so the pattern follows the
+ // locale, not just the month names.
if (start.year() !== end.year()) {
- return `${start.format('MMM D, YYYY')} - ${end.format('MMM D, YYYY')}`;
- }
-
- if (start.month() === end.month()) {
- return `${start.format('MMM D')}-${end.format('D, YYYY')}`;
+ return `${formatShortDateWithYear(start)} - ${formatShortDateWithYear(end)}`;
}
-
- return `${start.format('MMM D')} - ${end.format('MMM D, YYYY')}`;
+ return `${formatShortDate(start)} - ${formatShortDateWithYear(end)}`;
};
const getCurrentPeriodLabel = () => {
@@ -1136,15 +1137,15 @@ const CalendarWidget = ({
return formatDateRangeLabel(start, start.clone().add(monthViewWeeksToShow * 7 - 1, 'days'));
}
- return moment(currentDate).format('MMMM YYYY');
+ return formatMonthYear(currentDate);
} else {
const startOfWeek = getWeekStartDate();
const endOfWeek = startOfWeek.clone().add(6, 'days');
if (startOfWeek.month() === endOfWeek.month()) {
- return `${startOfWeek.format('MMM D')}-${endOfWeek.format('D, YYYY')}`;
+ return `${formatShortDate(startOfWeek)} - ${formatShortDateWithYear(endOfWeek)}`;
} else {
- return `${startOfWeek.format('MMM D')} - ${endOfWeek.format('MMM D, YYYY')}`;
+ return `${formatShortDate(startOfWeek)} - ${formatShortDateWithYear(endOfWeek)}`;
}
}
};
@@ -1178,7 +1179,7 @@ const CalendarWidget = ({
p: 3
}}>
- Loading calendar events...
+ {t('calendar:widget.loading')}
);
}
@@ -1202,7 +1203,7 @@ const CalendarWidget = ({
onClick={handlePreviousPeriod}
size="small"
sx={{ color: 'var(--text-color)' }}
- aria-label="Previous period"
+ aria-label={t('calendar:widget.previousPeriod')}
>
@@ -1213,7 +1214,7 @@ const CalendarWidget = ({
onClick={handleNextPeriod}
size="small"
sx={{ color: 'var(--text-color)' }}
- aria-label="Next period"
+ aria-label={t('calendar:widget.nextPeriod')}
>
@@ -1229,10 +1230,10 @@ const CalendarWidget = ({
'& .MuiToggleButton-root.Mui-selected': { color: 'var(--text-color)', backgroundColor: 'rgba(var(--accent-rgb), 0.15)' },
}}
>
-
+
-
+
@@ -1267,15 +1268,18 @@ const CalendarWidget = ({
const headerLabels = (() => {
if (isRollingMonthView) {
- return Array.from({ length: monthColumns }, (_, idx) => rollingStartDate.clone().add(idx, 'days').format('ddd'));
+ return Array.from({ length: monthColumns }, (_, idx) => formatWeekdayShort(rollingStartDate.clone().add(idx, 'days')));
}
if (isFirstDayOfMonthMode) {
- return Array.from({ length: 7 }, (_, idx) => monthStart.clone().add(idx, 'days').format('ddd'));
+ return Array.from({ length: 7 }, (_, idx) => formatWeekdayShort(monthStart.clone().add(idx, 'days')));
}
+ // Localized short weekday names, rotated to the tab's chosen
+ // start day. The calendar has had its own explicit week-start
+ // setting since #127, so it is never guessed from the locale.
const firstDayIndex = WEEKDAY_INDEX[dayOfWeekSettings.monthViewStart] ?? 0;
- return Array.from({ length: 7 }, (_, idx) => WEEKDAY_LABELS[(firstDayIndex + idx) % 7]);
+ return getWeekdayLabels(firstDayIndex);
})();
const rows = (() => {
@@ -1626,7 +1630,7 @@ const CalendarWidget = ({
{showWeekStartTimes && (
- {moment(event.start).format('h:mm A')}
+ {formatTime(event.start)}
)}
{selectedDate && (
- 📅 {moment(selectedDate).format('dddd, MMMM D, YYYY')}
+ 📅 {formatFullDate(selectedDate)}
)}
{selectedDateEvents.length === 0 ? (
- No events scheduled for this day.
+ {t('calendar:widget.noEventsForDay')}
) : (
@@ -1728,12 +1732,12 @@ const CalendarWidget = ({
{isGoogleEvent(event) && (
-
+
openEditEventDialog(event)}>
-
+
deleteEvent(event)}>
@@ -1749,7 +1753,7 @@ const CalendarWidget = ({
secondary={
- 🕐 {event.all_day ? 'All Day' : `${moment(event.start).format('h:mm A')} - ${moment(event.end).format('h:mm A')}`}
+ 🕐 {event.all_day ? t('calendar:event.allDay') : `${formatTime(event.start)} - ${formatTime(event.end)}`}
{event.location && (
@@ -1777,7 +1781,7 @@ const CalendarWidget = ({
startIcon={}
variant="outlined"
>
- Add event
+ {t('calendar:widget.addEvent')}
) : }
) : (
@@ -2015,7 +2019,7 @@ const CalendarWidget = ({
-
+
handleSyncSource(calendar.id)}
@@ -2069,7 +2073,7 @@ const CalendarWidget = ({
}
- label="Merge duplicate events across calendars"
+ label={t('calendar:settings.dedupe')}
/>
When the same event appears on more than one calendar (even with slightly
@@ -2078,32 +2082,33 @@ const CalendarWidget = ({
- Tab specific settings
+ {t('calendar:settings.tabSpecific')}
- Week View Start
+ {t('calendar:settings.weekViewStart')}
- Month View Start
+ {t('calendar:settings.monthViewStart')}