♻️(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;
|
||||
full_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 { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
@@ -6,41 +5,29 @@ 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, user),
|
||||
};
|
||||
return { label, isSelected, callback };
|
||||
});
|
||||
}, [conf?.LANGUAGES, i18n, language, synchronizeLanguage, user]);
|
||||
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]);
|
||||
|
||||
// Extract current language label for display
|
||||
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