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) => ( { )} @@ -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')} @@ -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) => ( { })} )} @@ -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')} @@ -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')} @@ -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 ? ( Chosen avatar ) : ( ? )} - 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')} @@ -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 }) => { @@ -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')} ) : } - Chore definitions hold the title, description, and clam value. Add schedules below to assign them to users with a recurrence pattern. + {t('chores:schedules.definitionsHelp')}
- Title - Description - Clams - Schedules - Actions + {t('common:labels.title')} + {t('common:labels.description')} + {t('chores:schedules.clams')} + {t('chores:schedules.schedulesColumn')} + {t('common:labels.actions')} {chores.length === 0 ? ( - No chores defined yet. + {t('chores:schedules.noChores')} ) : ( chores.map(c => ( - + {c.title} - + - {c.description || No description} + {c.description || {t('chores:schedules.noDescription')}} - + {c.clam_value > 0 ? : } - + - + openEditChore(c)}> - + setDeleteChoreDialog({ open: true, chore: c })}> @@ -595,37 +593,37 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { {/* ── SCHEDULES ────────────────────────────────────── */} - Schedules + {t('chores:schedules.schedulesColumn')} - Schedules link chores to users and specify when they recur. A chore can have multiple schedules for different users or frequencies. + {t('chores:schedules.schedulesHelp')} - Filter by User - setFilterUser(e.target.value)}> + {t('chores:schedules.allUsers')} + {t('chores:schedules.unassignedBonus')} {users.map(u => {u.username})} - Filter by Chore - setFilterChore(e.target.value)}> + {t('chores:schedules.allChores')} {chores.map(c => {c.title})} {(filterUser || filterChore) && ( )} @@ -638,26 +636,26 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) {
- Chore - Assigned To - Next Occurrence - Duration - Clams - Visible - Actions + {t('chores:schedules.chore')} + {t('chores:schedules.assignedTo')} + {t('chores:schedules.nextOccurrence')} + {t('chores:schedules.duration')} + {t('chores:schedules.clams')} + {t('chores:schedules.visible')} + {t('common:labels.actions')} {filteredSchedules.length === 0 ? ( - No schedules found. + {t('chores:schedules.noSchedules')} ) : ( filteredSchedules.map((s) => ( - + {s.title} {s.description && ( @@ -665,7 +663,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { )} - + + {getNextOccurrence(s.crontab)} - + {s.crontab && s.duration === 'until-completed' ? ( - + ) : s.crontab && s.duration === 'once-completed' ? ( - + ) : s.crontab ? ( - + ) : ( )} - + {s.clam_value > 0 ? : } - + handleToggleVisible(s)} /> - + openEditSchedule(s)}> - + openCopySchedule(s)}> - + setDeleteScheduleDialog({ open: true, schedule: s })}> @@ -751,7 +751,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { setChoreForm(f => ({ ...f, title: e.target.value }))} required @@ -759,7 +759,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { setChoreForm(f => ({ ...f, description: e.target.value }))} multiline @@ -767,7 +767,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { /> setChoreForm(f => ({ ...f, clam_value: parseInt(e.target.value) || 0 }))} @@ -777,7 +777,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { - + + @@ -834,10 +835,10 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { - Chore + {t('chores:schedules.chore')} updateScheduleForm({ user_id: e.target.value })} > - Unassigned (Bonus chore) + {t('chores:schedules.unassignedBonusChore')} {users.map(u => {u.username})} @@ -872,7 +873,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { onChange={(e) => updateScheduleForm({ isOneTime: e.target.checked })} /> } - label="One-time task (no recurrence)" + label={t('chores:schedules.oneTimeTask')} /> {!scheduleForm.isOneTime && ( @@ -882,21 +883,21 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { value={scheduleForm.scheduleMode} onChange={(e) => updateScheduleForm({ scheduleMode: e.target.value })} > - } label="Preset" /> - } label="Days of Week" /> - } label="Custom Crontab" /> + } label={t('chores:schedules.modePreset')} /> + } label={t('chores:schedules.modeDaysOfWeek')} /> + } label={t('chores:schedules.modeCustomCrontab')} /> - Duration + {t('chores:schedules.duration')} {scheduleForm.duration === 'until-completed' @@ -913,7 +914,7 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { { const digitsOnly = e.target.value.replace(/\D/g, ''); @@ -926,16 +927,16 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { - Sleep Unit + {t('chores:schedules.sleepUnit')} @@ -944,16 +945,16 @@ export default function ChoreSchedulesTab({ saveMessage, setSaveMessage }) { {scheduleForm.scheduleMode === 'preset' && ( - Schedule Preset + {t('chores:schedules.schedulePreset')} setNewChore({ ...newChore, user_id: e.target.value })} > - Bonus Chore (Unassigned) + {t('chores:add.bonusChoreUnassigned')} {users.map(user => ( {user.username} @@ -1438,14 +1447,14 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { color="primary" /> } - label="One-time chore (no recurrence)" + label={t('chores:add.oneTime')} /> {!newChore.is_one_time && ( - Select Days (choose one or more): + {t('chores:add.selectDays')} {daysOfWeek.map(day => ( @@ -1458,7 +1467,9 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { color="primary" /> } - label={day.charAt(0).toUpperCase() + day.slice(1)} + // Label is translated; `day` stays the English key that + // crontab conversion and the API depend on. + label={t(`chores:days.${day}`)} /> ))} @@ -1468,19 +1479,19 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { setNewChore({ ...newChore, clam_value: parseInt(e.target.value) || 0 })} /> - + @@ -1495,13 +1506,13 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { {choreMenu.schedule?.transferable !== 0 && users.filter(u => u.id !== 0 && u.id !== choreMenu.schedule?.user_id).length > 0 && ( - + )} {choreMenu.schedule?.can_snooze !== 0 && ( - + )} @@ -1516,7 +1527,7 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { Transfer "{transferDialog.schedule?.title}" - Who should take over this chore? + {t('chores:transfer.dialogTitle')} {users @@ -1542,20 +1553,20 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { {transferDialog.targetUserId && isUserDayComplete(transferDialog.targetUserId) && ( - They already finished today's chores and earned the daily reward. + {t('chores:transfer.alreadyFinished')} setTransferDialog(prev => ({ ...prev, mode: e.target.value }))} > - } label="Revoke current reward and assign" /> - } label="Keep current reward and assign" /> + } label={t('chores:transfer.revokeAndAssign')} /> + } label={t('chores:transfer.keepAndAssign')} /> {transferDialog.mode === 'keep' && ( setTransferDialog(prev => ({ ...prev, bonus: e.target.value }))} inputProps={{ min: 0 }} @@ -1566,9 +1577,9 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { )} - + @@ -1583,25 +1594,25 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { Snooze "{snoozeDialog.schedule?.title}" - The chore stays hidden and isn't required for the daily reward until this time. + {t('chores:snooze.explanation')} - setSnoozeDialog(prev => ({ ...prev, until: snoozePresetValue(1) }))} /> - setSnoozeDialog(prev => ({ ...prev, until: snoozePresetValue(3) }))} /> - setSnoozeDialog(prev => ({ ...prev, until: snoozePresetValue(7) }))} /> + setSnoozeDialog(prev => ({ ...prev, until: snoozePresetValue(1) }))} /> + setSnoozeDialog(prev => ({ ...prev, until: snoozePresetValue(3) }))} /> + setSnoozeDialog(prev => ({ ...prev, until: snoozePresetValue(7) }))} /> setSnoozeDialog(prev => ({ ...prev, until: e.target.value }))} InputLabelProps={{ shrink: true }} /> - - + + @@ -1610,7 +1621,7 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { open={pinGate.open} onClose={() => setPinGate({ open: false, onSuccess: null })} onVerify={handlePinVerify} - title="Confirm with Admin PIN" + title={t('common:pin.confirmWithAdminPin')} /> diff --git a/client/src/components/PhotoWidget.jsx b/client/src/components/PhotoWidget.jsx index fbeeafb..50f4777 100644 --- a/client/src/components/PhotoWidget.jsx +++ b/client/src/components/PhotoWidget.jsx @@ -2,9 +2,11 @@ import React, { useState, useEffect, useRef } from 'react'; import { Typography, Box, IconButton, Popover, Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Switch, FormControlLabel, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, ListItemSecondaryAction, CircularProgress, Alert, Chip } from '@mui/material'; import { Settings, Add, Delete, Edit, Refresh, ChevronLeft, ChevronRight, PlayArrow, Pause, CloudUpload } from '@mui/icons-material'; import axios from 'axios'; +import { useTranslation } from 'react-i18next'; import { API_BASE_URL } from '../utils/apiConfig.js'; const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { + const { t } = useTranslation(['photos', 'common']); const [photos, setPhotos] = useState([]); const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); const [loading, setLoading] = useState(true); @@ -164,7 +166,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { }; const handleDeleteSource = async (sourceId) => { - if (window.confirm('Are you sure you want to delete this photo source?')) { + if (window.confirm(t('photos:confirm.deleteSource'))) { try { await axios.delete(`${API_BASE_URL}/api/photo-sources/${sourceId}`); await fetchPhotoSources(); @@ -187,7 +189,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { setTestingConnection(false); } } else { - setTestResult({ success: false, message: 'Please save the photo source before testing' }); + setTestResult({ success: false, message: t('photos:errors.saveBeforeTesting') }); } }; @@ -204,7 +206,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { setShowSourceDialog(false); } catch (error) { console.error('Error saving photo source:', error); - alert('Failed to save photo source. Please try again.'); + alert(t('photos:errors.saveFailed')); } finally { setSavingSource(false); } @@ -276,7 +278,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { {!loading && !error && photos.length === 0 && ( - No photos available. Add a photo source in settings. + {t('photos:widget.noPhotos')} )} @@ -322,7 +324,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { Photo { > - Photo Sources + {t('photos:settings.sources')} - Photos Per View + {t('photos:settings.photosPerView')} { savePreference('PHOTO_WIDGET_PHOTO_SIZE', value); }} > - Auto (fit widget) - Extra Large (880px) - Large (720px) - Medium (580px) - Small (450px) - Extra Small (300px) + {t('photos:size.auto')} + {t('photos:size.extraLarge')} + {t('photos:size.large')} + {t('photos:size.medium')} + {t('photos:size.small')} + {t('photos:size.extraSmall')} - Transition Effect + {t('photos:settings.transitionEffect')} - Slideshow Speed + {t('photos:settings.slideshowSpeed')} @@ -543,7 +545,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { ))} {photoSources.length === 0 && ( - No photo sources configured + {t('photos:settings.noSources')} )} @@ -572,21 +574,21 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { setSourceForm({ ...sourceForm, name: e.target.value })} margin="normal" /> - Type + {t('photos:source.type')} @@ -594,29 +596,29 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { <> setSourceForm({ ...sourceForm, url: e.target.value })} margin="normal" placeholder="https://immich.example.com" - helperText="Your Immich server URL" + helperText={t('photos:source.immichUrlPlaceholder')} /> setSourceForm({ ...sourceForm, api_key: e.target.value })} margin="normal" - helperText="Get from Immich Settings → API Keys" + helperText={t('photos:source.apiKeyHelp')} /> setSourceForm({ ...sourceForm, album_id: e.target.value })} margin="normal" - helperText="Leave empty to show all photos" + helperText={t('photos:source.albumIdHelp')} /> )} @@ -625,13 +627,12 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { {!editingSource ? ( - Save this source first, then open the upload page to add photos from your device. + {t('photos:source.saveFirst')} ) : ( <> - Upload photos directly from your device. They will be stored on your HomeGlow - server and displayed in this widget. + {t('photos:source.uploadExplain')} - The upload page is mobile-friendly—open it on your phone to upload photos - straight from your camera roll. + {t('photos:source.mobileHint')} )} @@ -666,10 +666,10 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { disabled={testingConnection} startIcon={testingConnection ? : } > - Test Connection + {t('photos:source.testConnection')} )} - +