✨(frontend) Adds customization for translations
Part of customization PoC Signed-off-by: Robin Weber <weber@b1-systems.de>
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import { PropsWithChildren, useEffect } from 'react';
|
||||
import { PropsWithChildren, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useLanguageSynchronizer } from '@/features/language/';
|
||||
import { useAuthQuery } from '@/features/auth';
|
||||
import {
|
||||
useCustomTranslations,
|
||||
useSynchronizedLanguage,
|
||||
} from '@/features/language';
|
||||
import { useAnalytics } from '@/libs';
|
||||
import { CrispProvider, PostHogAnalytic } from '@/services';
|
||||
import { useSentryStore } from '@/stores/useSentryStore';
|
||||
@@ -13,10 +18,35 @@ import { useConfig } from './api/useConfig';
|
||||
|
||||
export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
const { data: conf } = useConfig();
|
||||
const { data: user } = useAuthQuery();
|
||||
const { setSentry } = useSentryStore();
|
||||
const { setTheme } = useCunninghamTheme();
|
||||
const { changeLanguageSynchronized } = useSynchronizedLanguage();
|
||||
const { customizeTranslations } = useCustomTranslations();
|
||||
const { AnalyticsProvider } = useAnalytics();
|
||||
const { synchronizeLanguage } = useLanguageSynchronizer();
|
||||
const { i18n } = useTranslation();
|
||||
const languageSynchronized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || languageSynchronized.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLanguage =
|
||||
user?.language ?? i18n.resolvedLanguage ?? i18n.language;
|
||||
|
||||
void changeLanguageSynchronized(targetLanguage, user).then(() => {
|
||||
languageSynchronized.current = true;
|
||||
});
|
||||
}, [user, i18n.resolvedLanguage, i18n.language, changeLanguageSynchronized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conf?.theme_customization?.translations) {
|
||||
return;
|
||||
}
|
||||
|
||||
customizeTranslations(conf.theme_customization.translations);
|
||||
}, [conf?.theme_customization?.translations, customizeTranslations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conf?.SENTRY_DSN) {
|
||||
@@ -34,10 +64,6 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
setTheme(conf.FRONTEND_THEME);
|
||||
}, [conf?.FRONTEND_THEME, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
void synchronizeLanguage();
|
||||
}, [synchronizeLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conf?.POSTHOG_KEY) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Resource } from 'i18next';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { Theme } from '@/cunningham/';
|
||||
@@ -7,9 +8,10 @@ import { PostHogConf } from '@/services';
|
||||
|
||||
interface ThemeCustomization {
|
||||
footer?: FooterType;
|
||||
translations?: Resource;
|
||||
}
|
||||
|
||||
interface ConfigResponse {
|
||||
export interface ConfigResponse {
|
||||
AI_FEATURE_ENABLED?: boolean;
|
||||
COLLABORATION_WS_URL?: string;
|
||||
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { css } from 'styled-components';
|
||||
|
||||
import { DropdownMenu, Icon, Text } from '@/components/';
|
||||
import { useConfig } from '@/core';
|
||||
import { useAuthQuery } from '@/features/auth';
|
||||
|
||||
import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer';
|
||||
import { getMatchingLocales } from './utils/locale';
|
||||
@@ -12,6 +13,7 @@ import { getMatchingLocales } from './utils/locale';
|
||||
export const LanguagePicker = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { data: conf } = useConfig();
|
||||
const { data: user } = useAuthQuery();
|
||||
const { synchronizeLanguage } = useLanguageSynchronizer();
|
||||
const language = i18n.languages[0];
|
||||
Settings.defaultLocale = language;
|
||||
@@ -28,7 +30,9 @@ export const LanguagePicker = () => {
|
||||
i18n
|
||||
.changeLanguage(backendLocale)
|
||||
.then(() => {
|
||||
void synchronizeLanguage('toBackend');
|
||||
if (conf?.LANGUAGES && user) {
|
||||
synchronizeLanguage(conf.LANGUAGES, user, 'toBackend');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error changing language', err);
|
||||
@@ -36,7 +40,7 @@ export const LanguagePicker = () => {
|
||||
};
|
||||
return { label, isSelected, callback };
|
||||
});
|
||||
}, [conf, i18n, language, synchronizeLanguage]);
|
||||
}, [conf?.LANGUAGES, i18n, language, synchronizeLanguage, user]);
|
||||
|
||||
// Extract current language label for display
|
||||
const currentLanguageLabel =
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useConfig } from '@/core';
|
||||
import { useAuthQuery } from '@/features/auth/api';
|
||||
import type { ConfigResponse } from '@/core/config/api/useConfig';
|
||||
import { User } from '@/features/auth';
|
||||
import { useChangeUserLanguage } from '@/features/language/api/useChangeUserLanguage';
|
||||
import { getMatchingLocales } from '@/features/language/utils/locale';
|
||||
import { availableFrontendLanguages } from '@/i18n/initI18n';
|
||||
|
||||
export const useLanguageSynchronizer = () => {
|
||||
const { data: conf, isSuccess: confInitialized } = useConfig();
|
||||
const { data: user, isSuccess: userInitialized } = useAuthQuery();
|
||||
const { i18n } = useTranslation();
|
||||
const { mutateAsync: changeUserLanguage } = useChangeUserLanguage();
|
||||
const languageSynchronizing = useRef(false);
|
||||
|
||||
const availableBackendLanguages = useMemo(() => {
|
||||
return conf?.LANGUAGES.map(([locale]) => locale);
|
||||
}, [conf?.LANGUAGES]);
|
||||
|
||||
const synchronizeLanguage = useCallback(
|
||||
async (direction?: 'toBackend' | 'toFrontend') => {
|
||||
if (
|
||||
languageSynchronizing.current ||
|
||||
!userInitialized ||
|
||||
!confInitialized ||
|
||||
!availableBackendLanguages ||
|
||||
!availableFrontendLanguages
|
||||
) {
|
||||
(
|
||||
languages: ConfigResponse['LANGUAGES'],
|
||||
user: User,
|
||||
direction?: 'toBackend' | 'toFrontend',
|
||||
) => {
|
||||
if (languageSynchronizing.current || !availableFrontendLanguages) {
|
||||
return;
|
||||
}
|
||||
languageSynchronizing.current = true;
|
||||
|
||||
try {
|
||||
const availableBackendLanguages = languages.map(([locale]) => locale);
|
||||
const userPreferredLanguages = user.language ? [user.language] : [];
|
||||
const setOrDetectedLanguages = i18n.languages;
|
||||
|
||||
@@ -41,25 +34,27 @@ export const useLanguageSynchronizer = () => {
|
||||
(userPreferredLanguages.length ? 'toFrontend' : 'toBackend');
|
||||
|
||||
if (direction === 'toBackend') {
|
||||
// Update user's preference from frontends's language
|
||||
const closestBackendLanguage =
|
||||
getMatchingLocales(
|
||||
availableBackendLanguages,
|
||||
setOrDetectedLanguages,
|
||||
)[0] || availableBackendLanguages[0];
|
||||
await changeUserLanguage({
|
||||
changeUserLanguage({
|
||||
userId: user.id,
|
||||
language: closestBackendLanguage,
|
||||
}).catch((error) => {
|
||||
console.error('Error changing user language', error);
|
||||
});
|
||||
} else {
|
||||
// Update frontends's language from user's preference
|
||||
const closestFrontendLanguage =
|
||||
getMatchingLocales(
|
||||
availableFrontendLanguages,
|
||||
userPreferredLanguages,
|
||||
)[0] || availableFrontendLanguages[0];
|
||||
if (i18n.resolvedLanguage !== closestFrontendLanguage) {
|
||||
await i18n.changeLanguage(closestFrontendLanguage);
|
||||
i18n.changeLanguage(closestFrontendLanguage).catch((error) => {
|
||||
console.error('Error changing frontend language', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -68,14 +63,7 @@ export const useLanguageSynchronizer = () => {
|
||||
languageSynchronizing.current = false;
|
||||
}
|
||||
},
|
||||
[
|
||||
i18n,
|
||||
user,
|
||||
userInitialized,
|
||||
confInitialized,
|
||||
availableBackendLanguages,
|
||||
changeUserLanguage,
|
||||
],
|
||||
[i18n, changeUserLanguage],
|
||||
);
|
||||
|
||||
return { synchronizeLanguage };
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import i18next, { Resource, i18n } from 'i18next';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { ConfigResponse } from '@/core/config/api/useConfig';
|
||||
import { safeLocalStorage } from '@/utils/storages';
|
||||
|
||||
export const useTranslationsCustomizer = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const translationsCustomizing = useRef(false);
|
||||
|
||||
const customizeTranslations = useCallback(
|
||||
(
|
||||
customTranslationsUrl: ConfigResponse['FRONTEND_CUSTOM_TRANSLATIONS_URL'],
|
||||
cacheKey: string = 'CUSTOM_TRANSLATIONS',
|
||||
) => {
|
||||
if (translationsCustomizing.current) {
|
||||
return;
|
||||
}
|
||||
translationsCustomizing.current = true;
|
||||
try {
|
||||
if (!customTranslationsUrl) {
|
||||
safeLocalStorage.setItem(cacheKey, '');
|
||||
} else {
|
||||
const previousTranslationsString = safeLocalStorage.getItem(cacheKey);
|
||||
if (previousTranslationsString) {
|
||||
const previousTranslations = JSON.parse(
|
||||
previousTranslationsString,
|
||||
) as Resource;
|
||||
try {
|
||||
applyTranslations(previousTranslations, i18n);
|
||||
} catch (err: unknown) {
|
||||
console.error('Error parsing cached translations:', err);
|
||||
safeLocalStorage.setItem(cacheKey, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Always update in background
|
||||
fetchAndCacheTranslations(customTranslationsUrl, cacheKey)
|
||||
.then((updatedTranslations) => {
|
||||
if (
|
||||
updatedTranslations &&
|
||||
JSON.stringify(updatedTranslations) !==
|
||||
previousTranslationsString
|
||||
) {
|
||||
applyTranslations(updatedTranslations, i18n);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('Error fetching custom translations:', err);
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Error updating custom translations:', err);
|
||||
} finally {
|
||||
translationsCustomizing.current = false;
|
||||
}
|
||||
},
|
||||
[i18n],
|
||||
);
|
||||
|
||||
const applyTranslations = (translations: Resource, i18n: i18n) => {
|
||||
Object.entries(translations).forEach(([lng, namespaces]) => {
|
||||
Object.entries(namespaces).forEach(([ns, value]) => {
|
||||
i18next.addResourceBundle(lng, ns, value, true, true);
|
||||
});
|
||||
});
|
||||
const currentLanguage = i18n.language;
|
||||
void i18next.changeLanguage(currentLanguage);
|
||||
};
|
||||
|
||||
const fetchAndCacheTranslations = (url: string, CACHE_KEY: string) => {
|
||||
return fetch(url).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch custom translations');
|
||||
}
|
||||
return response.json().then((customTranslations: Resource) => {
|
||||
safeLocalStorage.setItem(CACHE_KEY, JSON.stringify(customTranslations));
|
||||
return customTranslations;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return { customizeTranslations };
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './hooks/useLanguageSynchronizer';
|
||||
export * from './LanguagePicker';
|
||||
export * from './hooks';
|
||||
export * from './components';
|
||||
export * from './utils';
|
||||
|
||||
@@ -4,36 +4,38 @@ import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import resources from './translations.json';
|
||||
|
||||
export const availableFrontendLanguages: readonly string[] =
|
||||
Object.keys(resources);
|
||||
// Add an initialization guard
|
||||
let isInitialized = false;
|
||||
|
||||
i18next
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
debug: false,
|
||||
detection: {
|
||||
order: ['cookie', 'navigator'], // detection order
|
||||
caches: ['cookie'], // Use cookies to store the language preference
|
||||
lookupCookie: 'docs_language',
|
||||
cookieMinutes: 525600, // Expires after one year
|
||||
cookieOptions: {
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
// Initialize i18next with the base translations only once
|
||||
if (!isInitialized && !i18next.isInitialized) {
|
||||
isInitialized = true;
|
||||
|
||||
i18next
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
debug: false,
|
||||
detection: {
|
||||
order: ['cookie', 'navigator'],
|
||||
caches: ['cookie'],
|
||||
lookupCookie: 'docs_language',
|
||||
cookieMinutes: 525600,
|
||||
cookieOptions: {
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
},
|
||||
},
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
preload: availableFrontendLanguages,
|
||||
lowerCaseLng: true,
|
||||
nsSeparator: false,
|
||||
keySeparator: false,
|
||||
})
|
||||
.catch(() => {
|
||||
throw new Error('i18n initialization failed');
|
||||
});
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
lowerCaseLng: true,
|
||||
nsSeparator: false,
|
||||
keySeparator: false,
|
||||
})
|
||||
.catch((e) => console.error('i18n initialization failed:', e));
|
||||
}
|
||||
|
||||
export default i18next;
|
||||
|
||||
52
src/frontend/apps/impress/src/utils/storages.ts
Normal file
52
src/frontend/apps/impress/src/utils/storages.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @fileOverview This module provides utilities to interact with local storage safely.
|
||||
*/
|
||||
|
||||
interface SyncStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @namespace safeLocalStorage
|
||||
* @description A utility for safely interacting with localStorage.
|
||||
* It checks if the `window` object is defined before attempting to access localStorage,
|
||||
* preventing errors in environments where `window` is not available.
|
||||
*/
|
||||
export const safeLocalStorage: SyncStorage = {
|
||||
/**
|
||||
* Retrieves an item from localStorage.
|
||||
* @param {string} key - The key of the item to retrieve.
|
||||
* @returns {string | null} The item's value, or null if the item does not exist or if localStorage is not available.
|
||||
*/
|
||||
getItem: (key: string): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(key);
|
||||
},
|
||||
/**
|
||||
* Sets an item in localStorage.
|
||||
* @param {string} key - The key of the item to set.
|
||||
* @param {string} value - The value to set for the item.
|
||||
* @returns {void}
|
||||
*/
|
||||
setItem: (key: string, value: string): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(key, value);
|
||||
},
|
||||
/**
|
||||
* Removes an item from localStorage.
|
||||
* @param {string} key - The key of the item to remove.
|
||||
* @returns {void}
|
||||
*/
|
||||
removeItem: (key: string): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user