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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Help translate
+
+
+
+
+
+
+
+
+
+
+
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()
+ }
+}