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/docs/theming.md b/docs/theming.md index 6f8c65a96..c73ce885d 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -30,4 +30,18 @@ body { Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified. +---- +# **Custom Translations** 📝 + +The translations can be overridden from the theme customization file. + +### Settings 🔧 + +```shellscript +THEME_CUSTOMIZATION_FILE_PATH= +``` + +### Example of JSON + +The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json \ No newline at end of file diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index 6a47bed42..a448714d8 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -173,6 +173,36 @@ test.describe('Config', () => { .first(), ).toBeAttached(); }); + + test('it checks theme_customization.translations config', async ({ + page, + }) => { + await page.route('**/api/v1.0/config/', async (route) => { + const request = route.request(); + if (request.method().includes('GET')) { + await route.fulfill({ + json: { + ...CONFIG, + theme_customization: { + translations: { + en: { + translation: { + Docs: 'MyCustomDocs', + }, + }, + }, + }, + }, + }); + } else { + await route.continue(); + } + }); + + await page.goto('/'); + + await expect(page.getByText('MyCustomDocs')).toBeAttached(); + }); }); test.describe('Config: Not loggued', () => { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts index 5805d0104..048020837 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -128,8 +128,16 @@ export async function waitForLanguageSwitch( lang: TestLanguageValue, ) { const header = page.locator('header').first(); - await header.getByRole('button', { name: 'arrow_drop_down' }).click(); + const languagePicker = header.locator('.--docs--language-picker-text'); + const isAlreadyTargetLanguage = await languagePicker + .innerText() + .then((text) => text.toLowerCase().includes(lang.label.toLowerCase())); + if (isAlreadyTargetLanguage) { + return; + } + + await languagePicker.click(); const responsePromise = page.waitForResponse( (resp) => resp.url().includes('/user') && resp.request().method() === 'PATCH', 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..73e946c6d 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,6 +22,7 @@ interface ConfigResponse { MEDIA_BASE_URL?: string; POSTHOG_KEY?: PostHogConf; SENTRY_DSN?: string; + theme_customization?: ThemeCustomization; } const LOCAL_STORAGE_KEY = 'docs_config'; 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/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/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, + }; +} 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 61% rename from src/frontend/apps/impress/src/features/language/LanguagePicker.tsx rename to src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx index 950b4f2bb..9bd2cc65f 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx @@ -1,42 +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 { 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 { 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(() => { - void synchronizeLanguage('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, i18n, language, synchronizeLanguage]); + }, [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 e6bb23b99..000000000 --- a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useCallback, useMemo, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { useConfig } from '@/core'; -import { useAuthQuery } from '@/features/auth/api'; -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 - ) { - return; - } - languageSynchronizing.current = true; - - try { - 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') { - // Update user's preference from frontends's language - const closestBackendLanguage = - getMatchingLocales( - availableBackendLanguages, - setOrDetectedLanguages, - )[0] || availableBackendLanguages[0]; - await changeUserLanguage({ - userId: user.id, - language: closestBackendLanguage, - }); - } 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); - } - } - } catch (error) { - console.error('Error synchronizing language', error); - } finally { - languageSynchronizing.current = false; - } - }, - [ - i18n, - user, - userInitialized, - confInitialized, - availableBackendLanguages, - 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/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/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'; 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); + }, +}; 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": [