From d9a8461a2ec2b65fd8ab214986e99858e2609c31 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Fri, 18 Oct 2024 19:55:44 +0200 Subject: [PATCH] Feature: Allow custom language pack for webapp --- webapp/src/components/LocaleSwitcher.vue | 27 ++--- webapp/src/i18n.ts | 145 +++++++++++++++++++++++ webapp/src/locales/index.ts | 78 ------------ webapp/src/main.ts | 14 +-- webapp/vite.config.ts | 2 + 5 files changed, 158 insertions(+), 108 deletions(-) create mode 100644 webapp/src/i18n.ts delete mode 100644 webapp/src/locales/index.ts diff --git a/webapp/src/components/LocaleSwitcher.vue b/webapp/src/components/LocaleSwitcher.vue index 4dc7545..be65b76 100644 --- a/webapp/src/components/LocaleSwitcher.vue +++ b/webapp/src/components/LocaleSwitcher.vue @@ -1,31 +1,24 @@ diff --git a/webapp/src/i18n.ts b/webapp/src/i18n.ts new file mode 100644 index 0000000..454e891 --- /dev/null +++ b/webapp/src/i18n.ts @@ -0,0 +1,145 @@ +import { createI18n } from 'vue-i18n'; +import messages from '@intlify/unplugin-vue-i18n/messages'; +import type { I18nOptions, IntlDateTimeFormat, IntlNumberFormat } from 'vue-i18n'; + +export const allLocales = [ + { code: 'en', name: 'English' }, + { code: 'de', name: 'Deutsch' }, + { code: 'fr', name: 'Français' }, +]; + +const dateTimeFormatsTemplate: IntlDateTimeFormat = { + datetime: { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour12: false, + }, +}; + +const numberFormatTemplate: IntlNumberFormat = { + decimal: { + style: 'decimal', + }, + decimalNoDigits: { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }, + decimalOneDigit: { + style: 'decimal', + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }, + decimalTwoDigits: { + style: 'decimal', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }, + percent: { + style: 'percent', + }, + percentOneDigit: { + style: 'percent', + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }, + byte: { + style: 'unit', + unit: 'byte', + }, + kilobyte: { + style: 'unit', + unit: 'kilobyte', + }, + megabyte: { + style: 'unit', + unit: 'megabyte', + }, + celsius: { + style: 'unit', + unit: 'celsius', + maximumFractionDigits: 1, + }, +}; + +export const dateTimeFormats: I18nOptions['datetimeFormats'] = {}; +export const numberFormats: I18nOptions['numberFormats'] = {}; + +allLocales.forEach((locale) => { + dateTimeFormats[locale.code] = dateTimeFormatsTemplate; + numberFormats[locale.code] = numberFormatTemplate; +}); + +export const i18n = createI18n({ + legacy: false, + globalInjection: true, + locale: navigator.language.split('-')[0], + fallbackLocale: allLocales[0].code, + messages, + datetimeFormats: dateTimeFormats, + numberFormats: numberFormats, +}); + +const dynamicLocales = await loadAvailLocales(); +allLocales.push(...dynamicLocales); + +if (localStorage.getItem('locale')) { + setLocale(localStorage.getItem('locale') || 'en'); +} else { + localStorage.setItem('locale', i18n.global.locale.value); +} + +// Set new locale. +export async function setLocale(locale: string) { + // Load locale if not available yet. + if (!i18n.global.availableLocales.includes(locale)) { + const messages = await loadLocale(locale); + + // fetch() error occurred. + if (messages === undefined) { + i18n.global.locale.value = allLocales[0].code; + return; + } + + // Add locale. + i18n.global.setLocaleMessage(locale, messages.webapp); + i18n.global.setNumberFormat(locale, numberFormatTemplate); + i18n.global.setDateTimeFormat(locale, dateTimeFormatsTemplate); + } + + // Set locale. + i18n.global.locale.value = locale; + localStorage.setItem('locale', i18n.global.locale.value); +} + +// Fetch locale. +async function loadLocale(locale: string) { + return fetch(`/api/i18n/language?code=${locale}`) + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error('Something went wrong!'); + }) + .catch((error) => { + console.error(error); + }); +} + +// Fetch available locales +async function loadAvailLocales() { + return fetch('/api/i18n/languages') + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error('Something went wrong!'); + }) + .catch((error) => { + console.error(error); + }); +} diff --git a/webapp/src/locales/index.ts b/webapp/src/locales/index.ts deleted file mode 100644 index 596a419..0000000 --- a/webapp/src/locales/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { I18nOptions } from 'vue-i18n'; - -export enum Locales { - EN = 'en', - DE = 'de', - FR = 'fr', -} - -export const LOCALES = [ - { value: Locales.EN, caption: 'English' }, - { value: Locales.DE, caption: 'Deutsch' }, - { value: Locales.FR, caption: 'Français' }, -]; - -export const dateTimeFormats: I18nOptions['datetimeFormats'] = {}; -export const numberFormats: I18nOptions['numberFormats'] = {}; - -LOCALES.forEach((locale) => { - dateTimeFormats[locale.value] = { - datetime: { - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour12: false, - }, - }; - - numberFormats[locale.value] = { - decimal: { - style: 'decimal', - }, - decimalNoDigits: { - style: 'decimal', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }, - decimalOneDigit: { - style: 'decimal', - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }, - decimalTwoDigits: { - style: 'decimal', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }, - percent: { - style: 'percent', - }, - percentOneDigit: { - style: 'percent', - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }, - byte: { - style: 'unit', - unit: 'byte', - }, - kilobyte: { - style: 'unit', - unit: 'kilobyte', - }, - megabyte: { - style: 'unit', - unit: 'megabyte', - }, - celsius: { - style: 'unit', - unit: 'celsius', - maximumFractionDigits: 1, - }, - }; -}); - -export const defaultLocale = Locales.EN; diff --git a/webapp/src/main.ts b/webapp/src/main.ts index 129da4c..ef2243e 100644 --- a/webapp/src/main.ts +++ b/webapp/src/main.ts @@ -1,11 +1,9 @@ -import messages from '@intlify/unplugin-vue-i18n/messages'; import mitt from 'mitt'; import { createApp } from 'vue'; -import { createI18n } from 'vue-i18n'; import App from './App.vue'; -import { dateTimeFormats, defaultLocale, numberFormats } from './locales'; import { tooltip } from './plugins/bootstrap'; import router from './router'; +import { i18n } from './i18n'; import 'bootstrap'; import './scss/styles.scss'; @@ -17,16 +15,6 @@ app.config.globalProperties.$emitter = emitter; app.directive('tooltip', tooltip); -const i18n = createI18n({ - legacy: false, - globalInjection: true, - locale: navigator.language.split('-')[0], - fallbackLocale: defaultLocale, - messages, - datetimeFormats: dateTimeFormats, - numberFormats: numberFormats, -}); - app.use(router); app.use(i18n); diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index 8977d6f..ed6b672 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ VueI18nPlugin({ /* options */ include: path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src/locales/**.json'), + runtimeOnly: false, fullInstall: false, forceStringify: true, strictMessage: false, @@ -55,6 +56,7 @@ export default defineConfig({ assetFileNames: "assets/[name].[ext]", }, }, + target: 'es2022', }, esbuild: { drop: ['console', 'debugger'],