From 382a851b7dd529b83fdd4e282365df6280c36879 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Wed, 28 May 2025 15:41:01 +0800 Subject: [PATCH] i18n support --- next-ui/eslint.config.js | 18 +- next-ui/package-lock.json | 540 +++++++++++++++++- next-ui/package.json | 6 +- next-ui/src/components.d.ts | 4 +- next-ui/src/components/LocaleSelector.vue | 53 ++ .../{app/bar => }/ThemeSelector.vue | 6 +- next-ui/src/components/app/bar/AppBar.vue | 1 + .../app/drawer/AppDrawerMenuServer.vue | 13 +- .../forms/user/FormUserChangePassword.vue | 2 +- next-ui/src/i18n/en.json | 10 + next-ui/src/i18n/fr.json | 10 + next-ui/src/plugins/index.ts | 2 + next-ui/src/plugins/vue-intl.ts | 10 + next-ui/src/plugins/vuetify.ts | 2 +- next-ui/src/utils/locale-helper.ts | 63 ++ 15 files changed, 730 insertions(+), 10 deletions(-) create mode 100644 next-ui/src/components/LocaleSelector.vue rename next-ui/src/components/{app/bar => }/ThemeSelector.vue (89%) create mode 100644 next-ui/src/i18n/en.json create mode 100644 next-ui/src/i18n/fr.json create mode 100644 next-ui/src/plugins/vue-intl.ts create mode 100644 next-ui/src/utils/locale-helper.ts diff --git a/next-ui/eslint.config.js b/next-ui/eslint.config.js index 53168fb2..4ff3e06c 100644 --- a/next-ui/eslint.config.js +++ b/next-ui/eslint.config.js @@ -6,6 +6,7 @@ import pluginVue from 'eslint-plugin-vue' import {defineConfigWithVueTs, vueTsConfigs} from '@vue/eslint-config-typescript' +import formatjs from 'eslint-plugin-formatjs' export default defineConfigWithVueTs( { @@ -32,5 +33,20 @@ export default defineConfigWithVueTs( ], 'vue/multi-word-component-names': 'off', } - } + }, + + { + plugins: { + formatjs, + }, + rules: { + 'formatjs/no-offset': 'error', + 'formatjs/enforce-id': [ + 'error', + { + idInterpolationPattern: '[sha512:contenthash:base64:6]', + }, + ], + }, + }, ) diff --git a/next-ui/package-lock.json b/next-ui/package-lock.json index d1c86dd9..c45df014 100644 --- a/next-ui/package-lock.json +++ b/next-ui/package-lock.json @@ -16,10 +16,12 @@ "openapi-fetch": "^0.14.0", "pinia-plugin-persistedstate": "^4.3.0", "vue": "^3.5.14", + "vue-intl": "^6.5.25", "vuetify": "^3.8.5" }, "devDependencies": { "@eslint/js": "^9.27.0", + "@formatjs/cli": "^6.7.1", "@mdi/font": "7.4.47", "@tsconfig/node22": "^22.0.2", "@types/node": "^22.15.21", @@ -27,6 +29,7 @@ "@vue/eslint-config-typescript": "^14.1.3", "@vue/tsconfig": "^0.7.0", "eslint": "^9.27.0", + "eslint-plugin-formatjs": "^5.3.1", "eslint-plugin-vue": "^10.1.0", "npm-run-all2": "^8.0.3", "openapi-typescript": "^7.8.0", @@ -689,6 +692,151 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@formatjs/cli": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@formatjs/cli/-/cli-6.7.1.tgz", + "integrity": "sha512-ULiXbLkbuTyd8f0qaByu1Nuc+jbAOLH1qRAtHZ7waIABQGPBB93OQ2FFtQPgoYoupKOKyNr+PZXR6pOT45E4EQ==", + "dev": true, + "license": "MIT", + "bin": { + "formatjs": "bin/formatjs" + }, + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "@glimmer/env": "^0.1.7", + "@glimmer/reference": "^0.94.0", + "@glimmer/syntax": "^0.94.9", + "@glimmer/validator": "^0.94.0", + "@vue/compiler-core": "^3.5.12", + "content-tag": "^3.0.0", + "ember-template-recast": "^6.1.5", + "vue": "^3.5.12" + }, + "peerDependenciesMeta": { + "@glimmer/env": { + "optional": true + }, + "@glimmer/reference": { + "optional": true + }, + "@glimmer/syntax": { + "optional": true + }, + "@glimmer/validator": { + "optional": true + }, + "@vue/compiler-core": { + "optional": true + }, + "content-tag": { + "optional": true + }, + "ember-template-recast": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz", + "integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.1", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz", + "integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/icu-skeleton-parser": "1.8.14", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.14", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz", + "integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-3.1.6.tgz", + "integrity": "sha512-tDkXnA4qpIFcDWac8CyVJq6oW8DR7W44QDUBsfXWIIJD/FYYen0QoH46W7XsVMFfPOVKkvbufjboZrrWbEfmww==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.2", + "intl-messageformat": "10.7.16", + "tslib": "^2.8.0" + }, + "peerDependencies": { + "typescript": "^5.6.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz", + "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/ts-transformer": { + "version": "3.13.34", + "resolved": "https://registry.npmjs.org/@formatjs/ts-transformer/-/ts-transformer-3.13.34.tgz", + "integrity": "sha512-N1n7dA+6dfHn/LDQXrUPOC90Z+7dsDB5cQIJeIysVwGhk8PRyYo2eaotDszZMjQp8+hvs98kJyekn1X7mK1yHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "2.11.2", + "@types/json-stable-stringify": "^1.1.0", + "@types/node": "^22.0.0", + "chalk": "^4.1.2", + "json-stable-stringify": "^1.1.1", + "tslib": "^2.8.0", + "typescript": "^5.6.0" + }, + "peerDependencies": { + "ts-jest": "^29" + }, + "peerDependenciesMeta": { + "ts-jest": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1568,6 +1716,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -1581,6 +1740,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-stable-stringify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.1.0.tgz", + "integrity": "sha512-ESTsHWB72QQq+pjUFIbEz9uSCZppD31YrVkbt2rnUciTYEvcwN6uZIhX5JZeBHqRlFJ41x/7MewCs7E2Qux6Cg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.15.21", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", @@ -1591,6 +1757,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/picomatch": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.2.tgz", + "integrity": "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -2406,6 +2579,56 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2626,6 +2849,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2638,6 +2867,24 @@ "resolved": "https://registry.npmjs.org/deep-pick-omit/-/deep-pick-omit-1.2.1.tgz", "integrity": "sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw==" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", @@ -2672,6 +2919,31 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex-xs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz", + "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -2689,6 +2961,39 @@ "resolved": "https://registry.npmjs.org/errx/-/errx-0.1.0.tgz", "integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", @@ -2804,6 +3109,27 @@ } } }, + "node_modules/eslint-plugin-formatjs": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-formatjs/-/eslint-plugin-formatjs-5.3.1.tgz", + "integrity": "sha512-RYXxDyCIcjzXXiclHhtXTJhRnR+HGyk4fyAke4j7Jtw8kCdVG/0rFeLqsIoJojxEB91+tkt0enX2LmDaXNhfdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "2.11.2", + "@formatjs/ts-transformer": "3.13.34", + "@types/eslint": "^9.6.1", + "@types/picomatch": "^3", + "@typescript-eslint/utils": "^8.27.0", + "magic-string": "^0.30.0", + "picomatch": "2 || 3 || 4", + "tslib": "^2.8.0", + "unicode-emoji-utils": "^1.2.0" + }, + "peerDependencies": { + "eslint": "^9.23.0" + } + }, "node_modules/eslint-plugin-vue": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.1.0.tgz", @@ -3121,6 +3447,55 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -3163,6 +3538,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -3180,6 +3568,45 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -3267,6 +3694,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/intl-messageformat": { + "version": "10.7.16", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", + "integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.2", + "tslib": "^2.8.0" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3325,6 +3764,13 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3394,6 +3840,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -3414,6 +3880,16 @@ "node": ">=6" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3552,6 +4028,16 @@ "node": ">= 18" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -3818,6 +4304,16 @@ "pathe": "^2.0.3" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -4786,6 +5282,24 @@ "node": ">=10" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5001,7 +5515,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, "license": "0BSD" }, "node_modules/type-check": { @@ -5099,6 +5612,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unicode-emoji-utils": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/unicode-emoji-utils/-/unicode-emoji-utils-1.3.1.tgz", + "integrity": "sha512-6PiQxmnlsOsqzZCZz0sykSyMy/r1HiJiOWWXV98+BDva583DU4CtBeyDNsi4wMYUIbjUtMs4RgAuyft0EKLoVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^2.0.0" + } + }, "node_modules/unimport": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/unimport/-/unimport-4.2.0.tgz", @@ -5674,6 +6197,21 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/vue-intl": { + "version": "6.5.25", + "resolved": "https://registry.npmjs.org/vue-intl/-/vue-intl-6.5.25.tgz", + "integrity": "sha512-r7mcl9O/sNF+k+pqiHW+Zu2dbxF7XkME4+cQz5JiZTGJAPRMWX1l5WIELE63/CAxV6EU3CfcryE+r98rYidJlA==", + "license": "ISC", + "dependencies": { + "@babel/types": "^7.26.10", + "@formatjs/icu-messageformat-parser": "2.11.2", + "@formatjs/intl": "3.1.6", + "tslib": "^2.8.0" + }, + "peerDependencies": { + "vue": "^3.5.12" + } + }, "node_modules/vue-router": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", diff --git a/next-ui/package.json b/next-ui/package.json index d32bfd6b..1b6df867 100644 --- a/next-ui/package.json +++ b/next-ui/package.json @@ -10,7 +10,8 @@ "build-only": "vite build", "type-check": "vue-tsc --build --force", "lint": "eslint . --fix", - "openapi-generate": "npx tsx ./openapi-generator.mts" + "openapi-generate": "npx tsx ./openapi-generator.mts", + "i18n-extract": "formatjs extract \"src/**/*.{ts,tsx,vue}\" --ignore=\"**/*.d.ts\" --out-file src/i18n/en.json" }, "dependencies": { "@pinia/colada": "^0.16.1", @@ -21,10 +22,12 @@ "openapi-fetch": "^0.14.0", "pinia-plugin-persistedstate": "^4.3.0", "vue": "^3.5.14", + "vue-intl": "^6.5.25", "vuetify": "^3.8.5" }, "devDependencies": { "@eslint/js": "^9.27.0", + "@formatjs/cli": "^6.7.1", "@mdi/font": "7.4.47", "@tsconfig/node22": "^22.0.2", "@types/node": "^22.15.21", @@ -32,6 +35,7 @@ "@vue/eslint-config-typescript": "^14.1.3", "@vue/tsconfig": "^0.7.0", "eslint": "^9.27.0", + "eslint-plugin-formatjs": "^5.3.1", "eslint-plugin-vue": "^10.1.0", "npm-run-all2": "^8.0.3", "openapi-typescript": "^7.8.0", diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts index 86723d8f..267be31e 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -25,11 +25,11 @@ declare module 'vue' { DialogConfirmEdit: typeof import('./components/dialogs/DialogConfirmEdit.vue')['default'] FormUserChangePassword: typeof import('./components/forms/user/FormUserChangePassword.vue')['default'] FormUserEdit: typeof import('./components/forms/user/FormUserEdit.vue')['default'] - FormUserRoles: typeof import('./components/forms/user/FormUserRoles.vue')['default'] HelloWorld: typeof import('./components/HelloWorld.vue')['default'] + LocaleSelector: typeof import('./components/LocaleSelector.vue')['default'] LoginForm: typeof import('./components/LoginForm.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] - ThemeSelector: typeof import('./components/app/bar/ThemeSelector.vue')['default'] + ThemeSelector: typeof import('./components/ThemeSelector.vue')['default'] } } diff --git a/next-ui/src/components/LocaleSelector.vue b/next-ui/src/components/LocaleSelector.vue new file mode 100644 index 00000000..a079e835 --- /dev/null +++ b/next-ui/src/components/LocaleSelector.vue @@ -0,0 +1,53 @@ + + + + + + + diff --git a/next-ui/src/components/app/bar/ThemeSelector.vue b/next-ui/src/components/ThemeSelector.vue similarity index 89% rename from next-ui/src/components/app/bar/ThemeSelector.vue rename to next-ui/src/components/ThemeSelector.vue index 6d1b3a8d..6df1e31e 100644 --- a/next-ui/src/components/app/bar/ThemeSelector.vue +++ b/next-ui/src/components/ThemeSelector.vue @@ -7,12 +7,16 @@ /> - + diff --git a/next-ui/src/components/app/bar/AppBar.vue b/next-ui/src/components/app/bar/AppBar.vue index e159c9cf..b535eec3 100644 --- a/next-ui/src/components/app/bar/AppBar.vue +++ b/next-ui/src/components/app/bar/AppBar.vue @@ -11,6 +11,7 @@ Komga + diff --git a/next-ui/src/components/app/drawer/AppDrawerMenuServer.vue b/next-ui/src/components/app/drawer/AppDrawerMenuServer.vue index a2b02d63..d10819f4 100644 --- a/next-ui/src/components/app/drawer/AppDrawerMenuServer.vue +++ b/next-ui/src/components/app/drawer/AppDrawerMenuServer.vue @@ -28,8 +28,17 @@ /> + > + + {{ + $formatMessage({ + description: 'Drawer menu for Server Settings', + defaultMessage: 'Settings', + id: '9yKJ2S' + }) + }} + + { - return (v: unknown) => other === v || err || 'Field must be same' + return (v: unknown) => other === v || err || 'Field must have the same value' }, }, }, vuetify.locale) diff --git a/next-ui/src/utils/locale-helper.ts b/next-ui/src/utils/locale-helper.ts new file mode 100644 index 00000000..d7fbda9c --- /dev/null +++ b/next-ui/src/utils/locale-helper.ts @@ -0,0 +1,63 @@ +import {defineMessage} from 'vue-intl' + +export const defaultLocale = 'en' + +const localeName = defineMessage({ + description: 'The name of the locale, shown in the language selection menu. Must be translated to the language\'s name', + defaultMessage: 'English', + id: 'localename' +}) + +/** + * Loads messages from a translation file by its locale code. + * If the translation file does not exist, loads the `defaultLocale` instead. + * @param locale the locale code, e.g. 'fr' + */ +export async function loadLocale(locale: string) { + const localeToLoad = locale in availableLocales ? locale : defaultLocale + const { default: messages } = await import(`@/i18n/${localeToLoad}.json`); + return messages; +} + +async function loadAvailableLocales(): Promise> { + const localeFiles = import.meta.glob('@/i18n/*.json') + const locales: Record = {} + for (const path in localeFiles) { + const matched = path.match(/([A-Za-z0-9-_]+)\./i) + if (matched && matched.length > 1) { + const locale = matched[1] + const messages = await localeFiles[path]!() as Record + locales[locale!] = messages[localeName.id]!.defaultMessage + } + } + return locales +} + +/** + * Available locales loaded from translation files. + * Key is the locale code (e.g. 'fr') + * Value is the locale name in its own locale (e.g. 'Français') + */ +export const availableLocales = await loadAvailableLocales() + +/** + * Gets the saved locale from localStorage. + * If the locale is not valid, defaults to 'en'. + */ +function getLocale(): string { + const storageLocale = localStorage.getItem('userLocale') ?? defaultLocale + return storageLocale in availableLocales ? storageLocale : defaultLocale +} + +export const currentLocale = getLocale() + +/** + * Save the locale to localStorage and reloads the window if it has changed. + * @param locale the new locale + */ +export function setLocale(locale: string) { + if(locale !== currentLocale) { + localStorage.setItem('userLocale', locale) + window.location.reload() + } +}