From 152ece5f02e688322e56128e4b9583058095bc5f Mon Sep 17 00:00:00 2001 From: rvveber Date: Tue, 8 Apr 2025 16:09:30 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8(frontend)=20Adds=20customization?= =?UTF-8?q?=20for=20translations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of customization PoC Signed-off-by: Robin Weber --- .../src/core/config/ConfigProvider.tsx | 13 +-- .../impress/src/core/config/api/useConfig.tsx | 37 +++----- .../src/features/language/LanguagePicker.tsx | 8 +- .../language/hooks/useLanguageSynchronizer.ts | 46 ++++------ .../hooks/useTranslationsCustomizer.ts | 85 +++++++++++++++++++ .../impress/src/features/language/index.ts | 5 +- .../apps/impress/src/i18n/initI18n.ts | 60 ++++++------- .../apps/impress/src/utils/storages.ts | 52 ++++++++++++ 8 files changed, 212 insertions(+), 94 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts create mode 100644 src/frontend/apps/impress/src/utils/storages.ts diff --git a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx index 371e7c356..9857fc30e 100644 --- a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx +++ b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx @@ -4,7 +4,10 @@ import { PropsWithChildren, useEffect } from 'react'; import { Box } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { useLanguageSynchronizer } from '@/features/language/'; +import { + useCustomTranslations, + useSynchronizedLanguage, +} from '@/features/language'; import { useAnalytics } from '@/libs'; import { CrispProvider, PostHogAnalytic } from '@/services'; import { useSentryStore } from '@/stores/useSentryStore'; @@ -16,7 +19,9 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => { const { setSentry } = useSentryStore(); const { setTheme } = useCunninghamTheme(); const { AnalyticsProvider } = useAnalytics(); - const { synchronizeLanguage } = useLanguageSynchronizer(); + + useCustomTranslations(); + useSynchronizedLanguage(); useEffect(() => { if (!conf?.SENTRY_DSN) { @@ -34,10 +39,6 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => { setTheme(conf.FRONTEND_THEME); }, [conf?.FRONTEND_THEME, setTheme]); - useEffect(() => { - void synchronizeLanguage(); - }, [synchronizeLanguage]); - useEffect(() => { if (!conf?.POSTHOG_KEY) { return; diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index fdfe8c97e..8998e65b4 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -1,10 +1,15 @@ import { useQuery } from '@tanstack/react-query'; +import { Resource } from 'i18next'; import { APIError, errorCauses, fetchAPI } from '@/api'; import { Theme } from '@/cunningham/'; import { PostHogConf } from '@/services'; -interface ConfigResponse { +interface ThemeCustomization { + translations?: Resource; +} + +export interface ConfigResponse { AI_FEATURE_ENABLED?: boolean; COLLABORATION_WS_URL?: string; CRISP_WEBSITE_ID?: string; @@ -17,47 +22,27 @@ interface ConfigResponse { MEDIA_BASE_URL?: string; POSTHOG_KEY?: PostHogConf; SENTRY_DSN?: string; -} - -const LOCAL_STORAGE_KEY = 'docs_config'; - -function getCachedTranslation() { - try { - const jsonString = localStorage.getItem(LOCAL_STORAGE_KEY); - return jsonString ? (JSON.parse(jsonString) as ConfigResponse) : undefined; - } catch { - return undefined; - } -} - -function setCachedTranslation(translations: ConfigResponse) { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(translations)); + theme_customization?: ThemeCustomization; } export const getConfig = async (): Promise => { const response = await fetchAPI(`config/`); if (!response.ok) { - throw new APIError('Failed to get the doc', await errorCauses(response)); + throw new APIError('Failed to get the config', await errorCauses(response)); } const config = response.json() as Promise; - setCachedTranslation(await config); return config; }; -export const KEY_CONFIG = 'config'; +export const QKEY_CONFIG = 'config'; export function useConfig() { - const cachedData = getCachedTranslation(); - const oneHour = 1000 * 60 * 60; - return useQuery({ - queryKey: [KEY_CONFIG], + queryKey: [QKEY_CONFIG], queryFn: () => getConfig(), - initialData: cachedData, - staleTime: oneHour, - initialDataUpdatedAt: Date.now() - oneHour, // Force initial data to be considered stale + staleTime: 1000 * 60 * 60, // 1 hour }); } diff --git a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx index 950b4f2bb..fd60e2208 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx @@ -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 = diff --git a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts index e6bb23b99..cded24a79 100644 --- a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts +++ b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts @@ -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 }; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts new file mode 100644 index 000000000..8ba3eaa7e --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts @@ -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 }; +}; diff --git a/src/frontend/apps/impress/src/features/language/index.ts b/src/frontend/apps/impress/src/features/language/index.ts index 4b60c8bd4..d3732c1e9 100644 --- a/src/frontend/apps/impress/src/features/language/index.ts +++ b/src/frontend/apps/impress/src/features/language/index.ts @@ -1,2 +1,3 @@ -export * from './hooks/useLanguageSynchronizer'; -export * from './LanguagePicker'; +export * from './hooks'; +export * from './components'; +export * from './utils'; diff --git a/src/frontend/apps/impress/src/i18n/initI18n.ts b/src/frontend/apps/impress/src/i18n/initI18n.ts index 40700e64e..090226407 100644 --- a/src/frontend/apps/impress/src/i18n/initI18n.ts +++ b/src/frontend/apps/impress/src/i18n/initI18n.ts @@ -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, }, - }, - interpolation: { - escapeValue: false, - }, - preload: availableFrontendLanguages, - lowerCaseLng: true, - nsSeparator: false, - keySeparator: false, - }) - .catch(() => { - throw new Error('i18n initialization failed'); - }); + lowerCaseLng: true, + nsSeparator: false, + keySeparator: false, + }) + .catch((e) => console.error('i18n initialization failed:', e)); +} export default i18next; diff --git a/src/frontend/apps/impress/src/utils/storages.ts b/src/frontend/apps/impress/src/utils/storages.ts new file mode 100644 index 000000000..b7f152c91 --- /dev/null +++ b/src/frontend/apps/impress/src/utils/storages.ts @@ -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); + }, +}; From c62bca7b0a3070e948a2c545c87d71f90d45b158 Mon Sep 17 00:00:00 2001 From: rvveber Date: Tue, 8 Apr 2025 16:42:38 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=9D(documentation)=20adds=20custom?= =?UTF-8?q?ization=20for=20translations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of customization PoC Signed-off-by: Robin Weber --- CHANGELOG.md | 4 +++- src/helm/env.d/dev/configuration/theme/demo.json | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a94744803..4776a35df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ## Added +- ✨(frontend) add customization for translations #857 - ✨(back) allow theme customnization using a configuration file #948 - ✨ Add a custom callout block to the editor #892 - 🚩(frontend) version MIT only #911 @@ -20,10 +21,11 @@ and this project adheres to - 📝(frontend) Update documentation - ✅(frontend) Improve tests coverage -### Removed +## Removed - 🔥(back) remove footer endpoint + ## [3.2.1] - 2025-05-06 ## Fixed diff --git a/src/helm/env.d/dev/configuration/theme/demo.json b/src/helm/env.d/dev/configuration/theme/demo.json index aeefb5366..23bb29fb6 100644 --- a/src/helm/env.d/dev/configuration/theme/demo.json +++ b/src/helm/env.d/dev/configuration/theme/demo.json @@ -1,4 +1,12 @@ { + "translations": { + "en": { + "translation": { + "Docs": "MyDocs", + "New doc": "+" + } + } + }, "footer": { "default": { "externalLinks": [ From 358964e15d34c7c1b1edabd2165032d6c74f79ad Mon Sep 17 00:00:00 2001 From: rvveber Date: Wed, 7 May 2025 13:29:51 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20Separate=20m?= =?UTF-8?q?utations=20from=20queries=20for=20auth=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces dedicated mutations (for authentication/user operations) separating them from queries to align with best practices for data fetching and state management. Queries remain responsible for READ operations, while mutations now handle CREATE, UPDATE, and DELETE actions (for user data) improving separation of concerns. Signed-off-by: Robin Weber --- .../impress/src/features/auth/api/index.ts | 1 + .../src/features/auth/api/useAuthMutation.ts | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/frontend/apps/impress/src/features/auth/api/useAuthMutation.ts diff --git a/src/frontend/apps/impress/src/features/auth/api/index.ts b/src/frontend/apps/impress/src/features/auth/api/index.ts index ce8db5d43..d078e2f4f 100644 --- a/src/frontend/apps/impress/src/features/auth/api/index.ts +++ b/src/frontend/apps/impress/src/features/auth/api/index.ts @@ -1,2 +1,3 @@ export * from './useAuthQuery'; +export * from './useAuthMutation'; export * from './types'; diff --git a/src/frontend/apps/impress/src/features/auth/api/useAuthMutation.ts b/src/frontend/apps/impress/src/features/auth/api/useAuthMutation.ts new file mode 100644 index 000000000..d97d77709 --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/api/useAuthMutation.ts @@ -0,0 +1,80 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { User } from './types'; +import { KEY_AUTH } from './useAuthQuery'; + +// Mutations are separated from queries to allow for better separation of concerns. +// Mutations are responsible for C (CREATE), U (UPDATE), and D (DELETE) in CRUD. + +// --- Create --- +function createUser(): Promise { + throw new Error('Not yet implemented.'); +} + +// --- Update --- +async function updateUser( + user: { id: User['id'] } & Partial>, +): Promise { + const response = await fetchAPI(`users/${user.id}/`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(user), + }); + if (!response.ok) { + throw new APIError( + `Failed to update user profile for user ${user.id}`, + await errorCauses(response, user), + ); + } + return response.json() as Promise; +} + +// --- Delete --- +function deleteUser(): Promise { + throw new Error('Not yet implemented.'); +} + +// NOTE: Consider renaming useAuthMutation to useUserMutation for clarity. +export function useAuthMutation() { + const queryClient = useQueryClient(); + + const createMutation = useMutation({ + mutationFn: createUser, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [KEY_AUTH] }); + }, + onError: (error) => { + console.error('Error creating user', error); + }, + }); + + const updateMutation = useMutation({ + mutationFn: updateUser, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [KEY_AUTH] }); + }, + onError: (error) => { + console.error('Error updating user', error); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: deleteUser, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [KEY_AUTH] }); + }, + onError: (error) => { + console.error('Error deleting user', error); + }, + }); + + return { + create: createMutation.mutateAsync, + update: updateMutation.mutateAsync, + delete: deleteMutation.mutateAsync, + }; +} From 6a7545312d2aa19b4d1ea8dfda1c268bf1154655 Mon Sep 17 00:00:00 2001 From: rvveber Date: Wed, 7 May 2025 15:23:29 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20Refactor=20l?= =?UTF-8?q?anguage-related=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactors useTranslationsCustomizer to useCustomTranslations - Refactors useCustomTranslations to use useCustomTranslationsQuery - Refactors "useLanguageSynchronizer" to "useSynchronizedLanguage" - Removes "useChangeUserLanguage" - To change the user language, use "useAuthMutation" instead - Refactors "LanguagePicker" to better reflect its component role - Refactors "LanguagePicker" to use "useSynchronizedLangue" Signed-off-by: Robin Weber --- .../impress/src/features/auth/api/types.ts | 2 +- .../src/features/header/components/Header.tsx | 2 +- .../features/home/components/HomeHeader.tsx | 2 +- .../language/api/useChangeUserLanguage.tsx | 45 -------- .../{ => components}/LanguagePicker.tsx | 39 ++----- .../src/features/language/components/index.ts | 1 + .../src/features/language/hooks/index.ts | 2 + .../language/hooks/useCustomTranslations.ts | 42 +++++++ .../language/hooks/useLanguageSynchronizer.ts | 70 ------------ .../language/hooks/useSynchronizedLanguage.ts | 105 ++++++++++++++++++ .../hooks/useTranslationsCustomizer.ts | 85 -------------- .../src/features/language/utils/index.ts | 1 + .../left-panel/components/LeftPanel.tsx | 2 +- 13 files changed, 167 insertions(+), 231 deletions(-) delete mode 100644 src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx rename src/frontend/apps/impress/src/features/language/{ => components}/LanguagePicker.tsx (57%) create mode 100644 src/frontend/apps/impress/src/features/language/components/index.ts create mode 100644 src/frontend/apps/impress/src/features/language/hooks/index.ts create mode 100644 src/frontend/apps/impress/src/features/language/hooks/useCustomTranslations.ts delete mode 100644 src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts create mode 100644 src/frontend/apps/impress/src/features/language/hooks/useSynchronizedLanguage.ts delete mode 100644 src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts create mode 100644 src/frontend/apps/impress/src/features/language/utils/index.ts diff --git a/src/frontend/apps/impress/src/features/auth/api/types.ts b/src/frontend/apps/impress/src/features/auth/api/types.ts index 6d911e516..680329d1c 100644 --- a/src/frontend/apps/impress/src/features/auth/api/types.ts +++ b/src/frontend/apps/impress/src/features/auth/api/types.ts @@ -11,5 +11,5 @@ export interface User { email: string; full_name: string; short_name: string; - language: string; + language?: string; } diff --git a/src/frontend/apps/impress/src/features/header/components/Header.tsx b/src/frontend/apps/impress/src/features/header/components/Header.tsx index 73b15d7e1..16bbb62d1 100644 --- a/src/frontend/apps/impress/src/features/header/components/Header.tsx +++ b/src/frontend/apps/impress/src/features/header/components/Header.tsx @@ -5,7 +5,7 @@ import IconDocs from '@/assets/icons/icon-docs.svg'; import { Box, StyledLink } from '@/components/'; import { useCunninghamTheme } from '@/cunningham'; import { ButtonLogin } from '@/features/auth'; -import { LanguagePicker } from '@/features/language'; +import { LanguagePicker } from '@/features/language/components'; import { useResponsiveStore } from '@/stores'; import { HEADER_HEIGHT } from '../conf'; diff --git a/src/frontend/apps/impress/src/features/home/components/HomeHeader.tsx b/src/frontend/apps/impress/src/features/home/components/HomeHeader.tsx index 2c285d148..daa3f3cc5 100644 --- a/src/frontend/apps/impress/src/features/home/components/HomeHeader.tsx +++ b/src/frontend/apps/impress/src/features/home/components/HomeHeader.tsx @@ -6,7 +6,7 @@ import { Box } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { ButtonTogglePanel, Title } from '@/features/header/'; import { LaGaufre } from '@/features/header/components/LaGaufre'; -import { LanguagePicker } from '@/features/language'; +import { LanguagePicker } from '@/features/language/components'; import { useResponsiveStore } from '@/stores'; export const HEADER_HEIGHT = 91; diff --git a/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx b/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx deleted file mode 100644 index a11e1ac59..000000000 --- a/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; -import { User } from '@/features/auth/api/types'; - -export interface ChangeUserLanguageParams { - userId: User['id']; - language: User['language']; -} - -export const changeUserLanguage = async ({ - userId, - language, -}: ChangeUserLanguageParams): Promise => { - const response = await fetchAPI(`users/${userId}/`, { - method: 'PATCH', - body: JSON.stringify({ - language, - }), - }); - - if (!response.ok) { - throw new APIError( - `Failed to change the user language to ${language}`, - await errorCauses(response, { - value: language, - type: 'language', - }), - ); - } - - return response.json() as Promise; -}; - -export function useChangeUserLanguage() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: changeUserLanguage, - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: ['change-user-language'], - }); - }, - }); -} diff --git a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx similarity index 57% rename from src/frontend/apps/impress/src/features/language/LanguagePicker.tsx rename to src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx index fd60e2208..9bd2cc65f 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx @@ -1,46 +1,31 @@ -import { Settings } from 'luxon'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; 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'; +import { + getMatchingLocales, + useSynchronizedLanguage, +} from '@/features/language'; 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; + const { changeLanguageSynchronized } = useSynchronizedLanguage(); + const language = i18n.language; // Compute options for dropdown const optionsPicker = useMemo(() => { const backendOptions = conf?.LANGUAGES ?? [[language, language]]; - return backendOptions.map(([backendLocale, label]) => { - // Determine if the option is selected - const isSelected = - getMatchingLocales([backendLocale], [language]).length > 0; - // Define callback for updating both frontend and backend languages - const callback = () => { - i18n - .changeLanguage(backendLocale) - .then(() => { - if (conf?.LANGUAGES && user) { - synchronizeLanguage(conf.LANGUAGES, user, 'toBackend'); - } - }) - .catch((err) => { - console.error('Error changing language', err); - }); + return backendOptions.map(([backendLocale, backendLabel]) => { + return { + label: backendLabel, + isSelected: getMatchingLocales([backendLocale], [language]).length > 0, + callback: () => changeLanguageSynchronized(backendLocale), }; - return { label, isSelected, callback }; }); - }, [conf?.LANGUAGES, i18n, language, synchronizeLanguage, user]); + }, [changeLanguageSynchronized, conf?.LANGUAGES, language]); // Extract current language label for display const currentLanguageLabel = diff --git a/src/frontend/apps/impress/src/features/language/components/index.ts b/src/frontend/apps/impress/src/features/language/components/index.ts new file mode 100644 index 000000000..b5818aa74 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/components/index.ts @@ -0,0 +1 @@ +export * from './LanguagePicker'; diff --git a/src/frontend/apps/impress/src/features/language/hooks/index.ts b/src/frontend/apps/impress/src/features/language/hooks/index.ts new file mode 100644 index 000000000..5c6dd71a8 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useSynchronizedLanguage'; +export * from './useCustomTranslations'; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useCustomTranslations.ts b/src/frontend/apps/impress/src/features/language/hooks/useCustomTranslations.ts new file mode 100644 index 000000000..91423a80b --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/useCustomTranslations.ts @@ -0,0 +1,42 @@ +import { Resource } from 'i18next'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useConfig } from '@/core'; + +export const useCustomTranslations = () => { + const { i18n } = useTranslation(); + const { data: currentConfig } = useConfig(); + + const currentCustomTranslations: Resource = useMemo( + () => currentConfig?.theme_customization?.translations || {}, + [currentConfig], + ); + + // Overwrite translations with a resource + const customizeTranslations = useCallback( + (currentCustomTranslations: Resource) => { + Object.entries(currentCustomTranslations).forEach(([lng, namespaces]) => { + Object.entries(namespaces).forEach(([ns, value]) => { + i18n.addResourceBundle(lng, ns, value, true, true); + }); + }); + // trigger re-render + if (Object.entries(currentCustomTranslations).length > 0) { + void i18n.changeLanguage(i18n.language); + } + }, + [i18n], + ); + + useEffect(() => { + if (currentCustomTranslations) { + customizeTranslations(currentCustomTranslations); + } + }, [currentCustomTranslations, customizeTranslations]); + + return { + currentCustomTranslations, + customizeTranslations, + }; +}; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts deleted file mode 100644 index cded24a79..000000000 --- a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useCallback, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; - -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 { i18n } = useTranslation(); - const { mutateAsync: changeUserLanguage } = useChangeUserLanguage(); - const languageSynchronizing = useRef(false); - - const synchronizeLanguage = useCallback( - ( - 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; - - // Default direction depends on whether a user already has a language preference - direction = - direction ?? - (userPreferredLanguages.length ? 'toFrontend' : 'toBackend'); - - if (direction === 'toBackend') { - const closestBackendLanguage = - getMatchingLocales( - availableBackendLanguages, - setOrDetectedLanguages, - )[0] || availableBackendLanguages[0]; - changeUserLanguage({ - userId: user.id, - language: closestBackendLanguage, - }).catch((error) => { - console.error('Error changing user language', error); - }); - } else { - const closestFrontendLanguage = - getMatchingLocales( - availableFrontendLanguages, - userPreferredLanguages, - )[0] || availableFrontendLanguages[0]; - if (i18n.resolvedLanguage !== closestFrontendLanguage) { - i18n.changeLanguage(closestFrontendLanguage).catch((error) => { - console.error('Error changing frontend language', error); - }); - } - } - } catch (error) { - console.error('Error synchronizing language', error); - } finally { - languageSynchronizing.current = false; - } - }, - [i18n, changeUserLanguage], - ); - - return { synchronizeLanguage }; -}; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useSynchronizedLanguage.ts b/src/frontend/apps/impress/src/features/language/hooks/useSynchronizedLanguage.ts new file mode 100644 index 000000000..72391f3ed --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/useSynchronizedLanguage.ts @@ -0,0 +1,105 @@ +import debug from 'debug'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useConfig } from '@/core/config/api/useConfig'; +import { User, useAuthMutation, useAuthQuery } from '@/features/auth'; +import { getMatchingLocales } from '@/features/language/utils/locale'; + +const log = debug('features:language'); + +export const useSynchronizedLanguage = () => { + const { i18n } = useTranslation(); + const { data: currentUser } = useAuthQuery(); + const { update: updateUser } = useAuthMutation(); + const { data: config } = useConfig(); + + const availableFrontendLanguages = useMemo( + () => Object.keys(i18n?.options?.resources || { en: '<- fallback' }), + [i18n], + ); + const availableBackendLanguages = useMemo( + () => config?.LANGUAGES?.map(([locale]) => locale) || [], + [config?.LANGUAGES], + ); + const currentFrontendLanguage = useMemo( + () => i18n.resolvedLanguage || i18n.language, + [i18n], + ); + const currentBackendLanguage = useMemo( + () => currentUser?.language, + [currentUser], + ); + + const changeBackendLanguage = useCallback( + (language: string, user?: User) => { + const closestBackendLanguage = getMatchingLocales( + availableBackendLanguages, + [language], + )[0]; + + if (!user || user.language === closestBackendLanguage) { + return; + } + + log('Updating backend language (%O)', { + requested: language, + from: user.language, + to: closestBackendLanguage, + }); + void updateUser({ ...user, language: closestBackendLanguage }); + }, + [availableBackendLanguages, updateUser], + ); + + const changeFrontendLanguage = useCallback( + (language: string) => { + const closestFrontendLanguage = getMatchingLocales( + availableFrontendLanguages, + [language], + )[0]; + if (i18n.resolvedLanguage === closestFrontendLanguage) { + return; + } + + log('Updating frontend language (%O)', { + requested: language, + from: i18n.resolvedLanguage, + to: closestFrontendLanguage, + }); + void i18n.changeLanguage(closestFrontendLanguage); + }, + [availableFrontendLanguages, i18n], + ); + + const changeLanguageSynchronized = useCallback( + (language: string, user?: User) => { + changeFrontendLanguage(language); + changeBackendLanguage(language, user ?? currentUser); + }, + [changeBackendLanguage, changeFrontendLanguage, currentUser], + ); + + useEffect(() => { + if (currentBackendLanguage) { + changeLanguageSynchronized(currentBackendLanguage); + } else if (currentFrontendLanguage) { + changeLanguageSynchronized(currentFrontendLanguage); + } + }, [ + currentBackendLanguage, + currentFrontendLanguage, + changeLanguageSynchronized, + currentUser, + ]); + + return { + changeLanguageSynchronized, + changeFrontendLanguage, + changeBackendLanguage, + availableFrontendLanguages, + availableBackendLanguages, + currentFrontendLanguage, + currentBackendLanguage, + }; +}; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts deleted file mode 100644 index 8ba3eaa7e..000000000 --- a/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts +++ /dev/null @@ -1,85 +0,0 @@ -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 }; -}; diff --git a/src/frontend/apps/impress/src/features/language/utils/index.ts b/src/frontend/apps/impress/src/features/language/utils/index.ts new file mode 100644 index 000000000..5501675d5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/utils/index.ts @@ -0,0 +1 @@ +export * from './locale'; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx index f877e84e3..cb26b8756 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx @@ -6,7 +6,7 @@ import { Box, SeparatedSection } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { ButtonLogin } from '@/features/auth'; import { HEADER_HEIGHT } from '@/features/header/conf'; -import { LanguagePicker } from '@/features/language'; +import { LanguagePicker } from '@/features/language/components'; import { useResponsiveStore } from '@/stores'; import { useLeftPanelStore } from '../stores';