index.ts 5.2 KB

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