♻️(frontend) Refactor language-related code
- Refactors "useTranslationsCustomizer" to "useCustomTranslations" - Refactors "useLanguageSynchronizer" to "useSynchronizedLanguage" - Refactors "LanguagePicker" to better reflect its component role - Refactors "LanguagePicker" to use "useSynchronizedLangue" - Removes unused "useChangeUserLanguage" - To change the user language, use "useAuthMutation" instead Signed-off-by: Robin Weber <weber@b1-systems.de>
This commit is contained in:
@@ -11,5 +11,5 @@ export interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
short_name: string;
|
short_name: string;
|
||||||
language: string;
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<User> => {
|
|
||||||
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<User>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useChangeUserLanguage() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation<User, APIError, ChangeUserLanguageParams>({
|
|
||||||
mutationFn: changeUserLanguage,
|
|
||||||
onSuccess: () => {
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: ['change-user-language'],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Settings } from 'luxon';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
@@ -6,41 +5,29 @@ import { css } from 'styled-components';
|
|||||||
import { DropdownMenu, Icon, Text } from '@/components/';
|
import { DropdownMenu, Icon, Text } from '@/components/';
|
||||||
import { useConfig } from '@/core';
|
import { useConfig } from '@/core';
|
||||||
import { useAuthQuery } from '@/features/auth';
|
import { useAuthQuery } from '@/features/auth';
|
||||||
|
import {
|
||||||
import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer';
|
getMatchingLocales,
|
||||||
import { getMatchingLocales } from './utils/locale';
|
useSynchronizedLanguage,
|
||||||
|
} from '@/features/language';
|
||||||
|
|
||||||
export const LanguagePicker = () => {
|
export const LanguagePicker = () => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { data: conf } = useConfig();
|
const { data: conf } = useConfig();
|
||||||
const { data: user } = useAuthQuery();
|
const { data: user } = useAuthQuery();
|
||||||
const { synchronizeLanguage } = useLanguageSynchronizer();
|
const { changeLanguageSynchronized } = useSynchronizedLanguage();
|
||||||
const language = i18n.languages[0];
|
const language = i18n.language;
|
||||||
Settings.defaultLocale = language;
|
|
||||||
|
|
||||||
// Compute options for dropdown
|
// Compute options for dropdown
|
||||||
const optionsPicker = useMemo(() => {
|
const optionsPicker = useMemo(() => {
|
||||||
const backendOptions = conf?.LANGUAGES ?? [[language, language]];
|
const backendOptions = conf?.LANGUAGES ?? [[language, language]];
|
||||||
return backendOptions.map(([backendLocale, label]) => {
|
return backendOptions.map(([backendLocale, backendLabel]) => {
|
||||||
// Determine if the option is selected
|
return {
|
||||||
const isSelected =
|
label: backendLabel,
|
||||||
getMatchingLocales([backendLocale], [language]).length > 0;
|
isSelected: getMatchingLocales([backendLocale], [language]).length > 0,
|
||||||
// Define callback for updating both frontend and backend languages
|
callback: () => changeLanguageSynchronized(backendLocale, user),
|
||||||
const callback = () => {
|
|
||||||
i18n
|
|
||||||
.changeLanguage(backendLocale)
|
|
||||||
.then(() => {
|
|
||||||
if (conf?.LANGUAGES && user) {
|
|
||||||
synchronizeLanguage(conf.LANGUAGES, user, 'toBackend');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('Error changing language', err);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
return { label, isSelected, callback };
|
|
||||||
});
|
});
|
||||||
}, [conf?.LANGUAGES, i18n, language, synchronizeLanguage, user]);
|
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]);
|
||||||
|
|
||||||
// Extract current language label for display
|
// Extract current language label for display
|
||||||
const currentLanguageLabel =
|
const currentLanguageLabel =
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './LanguagePicker';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './useSynchronizedLanguage';
|
||||||
|
export * from './useCustomTranslations';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Resource } from 'i18next';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export const useCustomTranslations = () => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
|
// 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
customizeTranslations,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useUserUpdate } from '@/core/api/useUserUpdate';
|
||||||
|
import { useConfig } from '@/core/config/api/useConfig';
|
||||||
|
import { User } from '@/features/auth';
|
||||||
|
import { getMatchingLocales } from '@/features/language/utils/locale';
|
||||||
|
|
||||||
|
export const useSynchronizedLanguage = () => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const { mutateAsync: updateUser } = useUserUpdate();
|
||||||
|
const { data: config } = useConfig();
|
||||||
|
const isSynchronizingLanguage = useRef(false);
|
||||||
|
|
||||||
|
const availableFrontendLanguages = useMemo(
|
||||||
|
() => Object.keys(i18n?.options?.resources || { en: '<- fallback' }),
|
||||||
|
[i18n?.options?.resources],
|
||||||
|
);
|
||||||
|
const availableBackendLanguages = useMemo(
|
||||||
|
() => config?.LANGUAGES?.map(([locale]) => locale) || [],
|
||||||
|
[config?.LANGUAGES],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeBackendLanguage = useCallback(
|
||||||
|
async (language: string, user?: User) => {
|
||||||
|
const closestBackendLanguage = getMatchingLocales(
|
||||||
|
availableBackendLanguages,
|
||||||
|
[language],
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (user && user.language !== closestBackendLanguage) {
|
||||||
|
await updateUser({ id: user.id, language: closestBackendLanguage });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[availableBackendLanguages, updateUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeFrontendLanguage = useCallback(
|
||||||
|
async (language: string) => {
|
||||||
|
const closestFrontendLanguage = getMatchingLocales(
|
||||||
|
availableFrontendLanguages,
|
||||||
|
[language],
|
||||||
|
)[0];
|
||||||
|
if (
|
||||||
|
i18n.isInitialized &&
|
||||||
|
i18n.resolvedLanguage !== closestFrontendLanguage
|
||||||
|
) {
|
||||||
|
await i18n.changeLanguage(closestFrontendLanguage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[availableFrontendLanguages, i18n],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeLanguageSynchronized = useCallback(
|
||||||
|
async (language: string, user?: User) => {
|
||||||
|
if (!isSynchronizingLanguage.current) {
|
||||||
|
isSynchronizingLanguage.current = true;
|
||||||
|
await changeFrontendLanguage(language);
|
||||||
|
await changeBackendLanguage(language, user);
|
||||||
|
isSynchronizingLanguage.current = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[changeBackendLanguage, changeFrontendLanguage],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
changeLanguageSynchronized,
|
||||||
|
changeFrontendLanguage,
|
||||||
|
changeBackendLanguage,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './locale';
|
||||||
Reference in New Issue
Block a user