index.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. import i18n, { type BackendModule, type ResourceLanguage } from 'i18next';
  2. import { initReactI18next } from 'react-i18next';
  3. import LanguageDetector from 'i18next-browser-languagedetector';
  4. import { recoverFromMissingChunk } from '../utils/lazyPage';
  5. // English is bundled: it is the fallback for every missing key. The other
  6. // languages are separate files, and only the one in use is loaded (#3175).
  7. import en from './locales/en';
  8. const LOCALE_LOADERS: Record<string, () => Promise<{ default: ResourceLanguage }>> = {
  9. de: () => import('./locales/de'),
  10. es: () => import('./locales/es'),
  11. fr: () => import('./locales/fr'),
  12. ja: () => import('./locales/ja'),
  13. it: () => import('./locales/it'),
  14. nl: () => import('./locales/nl'),
  15. ko: () => import('./locales/ko'),
  16. 'pt-BR': () => import('./locales/pt-BR'),
  17. 'zh-CN': () => import('./locales/zh-CN'),
  18. 'zh-TW': () => import('./locales/zh-TW'),
  19. sv: () => import('./locales/sv'),
  20. tr: () => import('./locales/tr'),
  21. ru: () => import('./locales/ru'),
  22. uk: () => import('./locales/uk'),
  23. };
  24. /** Hands i18next a language's file when it needs that language. i18next
  25. * loads it before switching, so the interface never shows a half-loaded
  26. * language. */
  27. export const localeBackend: BackendModule = {
  28. type: 'backend',
  29. init() {},
  30. read(language, _namespace, callback) {
  31. const load = LOCALE_LOADERS[language];
  32. if (!load) {
  33. callback(null, {});
  34. return;
  35. }
  36. load().then(
  37. (module) => callback(null, module.default),
  38. (error) => {
  39. // A tab from before an update asks for a file the update deleted.
  40. void recoverFromMissingChunk().then((reloading) => {
  41. if (!reloading) callback(error, null);
  42. });
  43. },
  44. );
  45. },
  46. };
  47. const SUPPORTED_LNGS = ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'nl', 'pt-BR', 'ru', 'sv', 'tr', 'uk', 'zh-CN', 'zh-TW'];
  48. const APPLIANCE_CONSUMED_KEY = 'bambuddy_appliance_locale_consumed';
  49. /** Settles once the language in use has loaded (or failed to). */
  50. export const i18nReady = i18n
  51. .use(localeBackend)
  52. .use(LanguageDetector)
  53. .use(initReactI18next)
  54. .init({
  55. resources: { en: { translation: en } },
  56. // Load every other language through localeBackend.
  57. partialBundledLanguages: true,
  58. fallbackLng: 'en',
  59. supportedLngs: SUPPORTED_LNGS,
  60. detection: {
  61. // Order of detection methods
  62. order: ['localStorage', 'navigator', 'htmlTag'],
  63. // Key to use in localStorage
  64. lookupLocalStorage: 'bambutrack_language',
  65. // Cache user language
  66. caches: ['localStorage'],
  67. },
  68. interpolation: {
  69. escapeValue: false, // React already escapes
  70. },
  71. react: {
  72. useSuspense: false,
  73. },
  74. });
  75. /**
  76. * Bambuddy Appliance hook: on the first SPA load after the firstboot wizard
  77. * runs, /api/v1/system/appliance returns the locale the user picked. We
  78. * apply it once (gated by a localStorage flag) and stop. On non-appliance
  79. * installs the endpoint either 404s or returns nulls — silent no-op.
  80. *
  81. * This runs AFTER i18n.init so the LanguageDetector has already populated a
  82. * default; we override that default exactly once for fresh appliances. The
  83. * appliance is then "consumed" and the language picker is the only way to
  84. * change locale going forward (the wizard ran once; future intent comes from
  85. * the running UI).
  86. */
  87. function applyApplianceLocale() {
  88. if (typeof window === 'undefined' || !window.localStorage) return;
  89. const storage = window.localStorage;
  90. if (typeof storage.getItem !== 'function' || typeof storage.setItem !== 'function') return;
  91. if (storage.getItem(APPLIANCE_CONSUMED_KEY)) return;
  92. fetch('/api/v1/system/appliance')
  93. .then((r) => (r.ok ? r.json() : null))
  94. .then((data) => {
  95. if (!data || typeof data.locale !== 'string') return;
  96. if (!SUPPORTED_LNGS.includes(data.locale)) return;
  97. i18n.changeLanguage(data.locale);
  98. storage.setItem(APPLIANCE_CONSUMED_KEY, '1');
  99. })
  100. .catch(() => {
  101. // Endpoint absent or unreachable — non-appliance install or dev environment.
  102. // Leave the detector's choice in place.
  103. });
  104. }
  105. applyApplianceLocale();
  106. export default i18n;
  107. // Helper to get available languages
  108. export const availableLanguages = [
  109. { code: 'en', name: 'English', nativeName: 'English' },
  110. { code: 'de', name: 'German', nativeName: 'Deutsch' },
  111. { code: 'es', name: 'Spanish', nativeName: 'Español' },
  112. { code: 'fr', name: 'French', nativeName: 'Français' },
  113. { code: 'ja', name: 'Japanese', nativeName: '日本語' },
  114. { code: 'it', name: 'Italian', nativeName: 'Italiano' },
  115. { code: 'ko', name: 'Korean', nativeName: '한국어' },
  116. { code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
  117. { code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
  118. { code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
  119. { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' },
  120. { code: 'sv', name: 'Swedish', nativeName: 'Svenska' },
  121. { code: 'tr', name: 'Turkish', nativeName: 'Türkçe' },
  122. { code: 'ru', name: 'Russian', nativeName: 'Русский' },
  123. { code: 'uk', name: 'Ukrainian', nativeName: 'Українська' },
  124. ];