diff --git a/src/central-server/admin-service/ui/src/components/layout/LanguageDropdown.vue b/src/central-server/admin-service/ui/src/components/layout/LanguageDropdown.vue new file mode 100644 index 0000000000..0130d24694 --- /dev/null +++ b/src/central-server/admin-service/ui/src/components/layout/LanguageDropdown.vue @@ -0,0 +1,95 @@ + + + + + + diff --git a/src/central-server/admin-service/ui/src/components/layout/TabsBase.vue b/src/central-server/admin-service/ui/src/components/layout/TabsBase.vue index b87c9c95e3..59cd1cef86 100644 --- a/src/central-server/admin-service/ui/src/components/layout/TabsBase.vue +++ b/src/central-server/admin-service/ui/src/components/layout/TabsBase.vue @@ -39,6 +39,7 @@ $t(tab.name) }} + @@ -51,11 +52,13 @@ import AppIcon from './AppIcon.vue'; import AppDropMenu from './UserDropMenu.vue'; import { mapState } from 'pinia'; import { useUser } from '@/store/modules/user'; +import LanguageDropdown from './LanguageDropdown.vue'; export default defineComponent({ components: { AppIcon, AppDropMenu, + LanguageDropdown, }, data() { return { diff --git a/src/central-server/admin-service/ui/src/components/layout/UserDropMenu.vue b/src/central-server/admin-service/ui/src/components/layout/UserDropMenu.vue index 81d421aac1..b60a27188e 100644 --- a/src/central-server/admin-service/ui/src/components/layout/UserDropMenu.vue +++ b/src/central-server/admin-service/ui/src/components/layout/UserDropMenu.vue @@ -77,7 +77,6 @@ export default defineComponent({ diff --git a/src/security-server/admin-service/ui/src/components/layout/TabsBase.vue b/src/security-server/admin-service/ui/src/components/layout/TabsBase.vue index 185592d821..4742b8d08d 100644 --- a/src/security-server/admin-service/ui/src/components/layout/TabsBase.vue +++ b/src/security-server/admin-service/ui/src/components/layout/TabsBase.vue @@ -43,6 +43,7 @@ >{{ $t(tab.name) }} + @@ -55,11 +56,13 @@ import AppIcon from './AppIcon.vue'; import AppDropMenu from './AppDropMenu.vue'; import { mapState } from 'pinia'; import { useUser } from '@/store/modules/user'; +import LanguageDropdown from '@/components/layout/LanguageDropdown.vue'; export default defineComponent({ components: { AppIcon, AppDropMenu, + LanguageDropdown, }, data() { return { diff --git a/src/security-server/admin-service/ui/src/main.ts b/src/security-server/admin-service/ui/src/main.ts index 2be42f1503..9e6f557625 100644 --- a/src/security-server/admin-service/ui/src/main.ts +++ b/src/security-server/admin-service/ui/src/main.ts @@ -30,7 +30,7 @@ Sets up plugins and 3rd party components that the app uses. Creates a new Vue instance with the Vue function. Initialises the app root component. */ -import { createApp } from 'vue'; +import { createApp, nextTick } from 'vue'; import axios from 'axios'; import { XrdButton, @@ -66,11 +66,12 @@ import router from './router'; import '@fontsource/open-sans/800.css'; import '@fontsource/open-sans/700.css'; import '@fontsource/open-sans'; -import { i18n } from './plugins/i18n'; +import { i18n, setLanguage } from './plugins/i18n'; import { createPinia } from 'pinia'; import { createPersistedState } from 'pinia-plugin-persistedstate'; import { createFilters } from '@/filters'; import { createValidators } from '@/plugins/vee-validate'; +import { useLanguage } from '@/store/modules/language'; const pinia = createPinia(); pinia.use( @@ -116,3 +117,6 @@ app.component('XrdFileUpload', XrdFileUpload); app.component('XrdFormLabel', XrdFormLabel); app.component('XrdExpandable', XrdExpandable); app.mount('#app'); +// translations +const languageStorage = useLanguage(); +nextTick(() => setLanguage(languageStorage.getLanguage)).then(); diff --git a/src/security-server/admin-service/ui/src/plugins/i18n.ts b/src/security-server/admin-service/ui/src/plugins/i18n.ts index 4c17ec7695..c0a2936b7b 100644 --- a/src/security-server/admin-service/ui/src/plugins/i18n.ts +++ b/src/security-server/admin-service/ui/src/plugins/i18n.ts @@ -24,26 +24,93 @@ * THE SOFTWARE. */ import { createI18n } from 'vue-i18n'; -import veeEn from '@vee-validate/i18n/dist/locale/en.json'; -import en from '@/locales/en.json'; +import enValidationMessages from '@vee-validate/i18n/dist/locale/en.json'; import merge from 'deepmerge'; - import { messages } from '@niis/shared-ui'; +import enAppMessages from '@/locales/en.json'; + +const loadedLanguages = new Set('en'); +export const availableLanguages = ['en']; -const validation = { validation: veeEn }; +const defaultLanguage = import.meta.env.VITE_I18N_LOCALE || 'en'; +const defaultFallbackLanguage = import.meta.env.VITE_FALLBACK_LOCALE || 'en'; -type Shared = typeof messages.en; -type Vee = typeof validation; -type En = typeof en; -export type MessageSchema = Vee & Shared & En; +const sharedLanguageMessages = { + en: messages.en, +}; +const defaultLanguagePack = merge.all([ + { validation: enValidationMessages }, + sharedLanguageMessages.en, + enAppMessages, +]); -let common = merge(validation, messages.en); -common = merge(common, en); -export const i18n = createI18n<[MessageSchema], 'en'>({ +// Initialize i18n instance with default configuration +export const i18n = createI18n({ legacy: false, - locale: import.meta.env.VITE_VUE_APP_I18N_LOCALE || 'en', - fallbackLocale: import.meta.env.VITE_VUE_APP_I18N_FALLBACK_LOCALE || 'en', + locale: defaultLanguage, + fallbackLocale: defaultFallbackLanguage, silentFallbackWarn: true, allowComposition: true, - messages: { en: common as MessageSchema }, + messages: { en: defaultLanguagePack }, }); + +// Sets the active language, loading language pack if necessary +export async function setLanguage(language) { + await loadLanguagePackIfNeeded(language); + i18n.global.locale.value = language; +} + +// Loads language pack if it's not already loaded +async function loadLanguagePackIfNeeded(language) { + if (!loadedLanguages.has(language)) { + const messages = await fetchLanguageMessages(language); + const languagePack = mergeLanguageMessages(messages); + i18n.global.setLocaleMessage(language, languagePack); + loadedLanguages.add(language); + } +} + +// Fetches all language-specific messages for the given language +async function fetchLanguageMessages(language) { + const appMessagesPromise = import(`@/locales/${language}.json`).then( + (module) => module.default, + ); + + const [appMessages, validationMessages, sharedMessages] = await Promise.all([ + appMessagesPromise, + loadValidationMessages(language), + loadSharedMessages(language), + ]); + + return { appMessages, validationMessages, sharedMessages }; +} + +// Loads validation messages, with fallback to English if not available +async function loadValidationMessages(language) { + try { + const messages = await import( + `@vee-validate/i18n/dist/locale/${language}.json` + ); + return messages.default; + } catch { + return enValidationMessages; + } +} + +// Loads shared messages based on language +function loadSharedMessages(language) { + return sharedLanguageMessages[language] || sharedLanguageMessages.en; +} + +// Merges application, validation, and shared messages into a single pack +function mergeLanguageMessages({ + appMessages, + validationMessages, + sharedMessages, +}) { + return merge.all([ + { validation: validationMessages }, + sharedMessages, + appMessages, + ]); +} diff --git a/src/security-server/admin-service/ui/src/store/modules/language.ts b/src/security-server/admin-service/ui/src/store/modules/language.ts new file mode 100644 index 0000000000..0a72b1f54f --- /dev/null +++ b/src/security-server/admin-service/ui/src/store/modules/language.ts @@ -0,0 +1,51 @@ +/* + * The MIT License + * Copyright (c) 2019- Nordic Institute for Interoperability Solutions (NIIS) + * Copyright (c) 2018 Estonian Information System Authority (RIA), + * Nordic Institute for Interoperability Solutions (NIIS), Population Register Centre (VRK) + * Copyright (c) 2015-2017 Estonian Information System Authority (RIA), Population Register Centre (VRK) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import { defineStore } from 'pinia'; +import { setLanguage } from '@/plugins/i18n'; + +export const useLanguage = defineStore('language', { + state: () => ({ + language: import.meta.env.VITE_I18N_LOCALE || ('en' as string), + }), + + persist: { + storage: localStorage, + }, + + getters: { + getLanguage(state): string { + return state.language; + }, + }, + + actions: { + async changeLanguage(language: string) { + this.language = language; + await setLanguage(language); + }, + }, +});