From b8647538fd428639137496ea6f7874c2f5e5b815 Mon Sep 17 00:00:00 2001 From: alexsparkes Date: Sat, 24 Jan 2026 17:02:41 +0000 Subject: [PATCH] feat(translation): implement reactive translation system with context and hooks --- src/App.jsx | 7 ++- .../Elements/MainModal/backend/Tab.jsx | 2 + .../Elements/MainModal/backend/Tabs.jsx | 14 +++++ .../MainModal/components/ModalTopBar.jsx | 7 ++- src/components/Form/Settings/Radio/Radio.jsx | 14 +++++ src/contexts/README.md | 43 +++++++++++++ src/contexts/TranslationContext.jsx | 63 +++++++++++++++++++ src/contexts/index.js | 1 + src/features/misc/sections/Language.jsx | 61 +++++++++++------- src/features/misc/views/Settings.jsx | 18 +++++- src/features/navbar/components/Refresh.jsx | 6 +- .../components/Sections/ChooseLanguage.jsx | 8 ++- vite.config.mjs | 1 + 13 files changed, 210 insertions(+), 35 deletions(-) create mode 100644 src/contexts/README.md create mode 100644 src/contexts/TranslationContext.jsx create mode 100644 src/contexts/index.js diff --git a/src/App.jsx b/src/App.jsx index f20d1023..157918e1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,6 +6,7 @@ import Modals from 'features/misc/modals/Modals'; import { loadSettings, moveSettings } from 'utils/settings'; import EventBus from 'utils/eventbus'; import variables from 'config/variables'; +import { TranslationProvider } from 'contexts/TranslationContext'; const useAppSetup = () => { useEffect(() => { @@ -54,8 +55,10 @@ const App = () => { useAppSetup(); + const languagecode = localStorage.getItem('language') || 'en_GB'; + return ( - <> + {showBackground && } { - + ); }; diff --git a/src/components/Elements/MainModal/backend/Tab.jsx b/src/components/Elements/MainModal/backend/Tab.jsx index fddaeffe..756f7c74 100644 --- a/src/components/Elements/MainModal/backend/Tab.jsx +++ b/src/components/Elements/MainModal/backend/Tab.jsx @@ -1,8 +1,10 @@ import variables from 'config/variables'; import { memo, useState, useEffect } from 'react'; +import { useTranslation } from 'contexts/TranslationContext'; import { getIconComponent, DIVIDER_LABELS } from '../constants/tabConfig'; function Tab({ label, currentTab, onClick, navbarTab }) { + const { languagecode } = useTranslation(); const [isExperimental, setIsExperimental] = useState(true); useEffect(() => { diff --git a/src/components/Elements/MainModal/backend/Tabs.jsx b/src/components/Elements/MainModal/backend/Tabs.jsx index 44a47543..c543515c 100644 --- a/src/components/Elements/MainModal/backend/Tabs.jsx +++ b/src/components/Elements/MainModal/backend/Tabs.jsx @@ -1,5 +1,6 @@ import variables from 'config/variables'; import { useState, useEffect } from 'react'; +import { useTranslation } from 'contexts/TranslationContext'; import Tab from './Tab'; import ReminderInfo from '../components/ReminderInfo'; import ErrorBoundary from '../../../../features/misc/modals/ErrorBoundary'; @@ -15,6 +16,8 @@ const Tabs = ({ navigationTrigger, sections, }) => { + const { languagecode } = useTranslation(); + // Find initial section from deep link if available const getInitialSection = () => { if (deepLinkData?.section && sections) { @@ -58,6 +61,17 @@ const Tabs = ({ } }, []); + // Update labels when language changes + useEffect(() => { + if (sections && currentName) { + const section = sections.find((s) => s.name === currentName); + if (section) { + const newLabel = variables.getMessage(section.label); + setCurrentTab(newLabel); + } + } + }, [languagecode]); + // Handle navigation trigger for settings sections (popstate) useEffect(() => { if (navigationTrigger?.type === 'settings-section' && sections) { diff --git a/src/components/Elements/MainModal/components/ModalTopBar.jsx b/src/components/Elements/MainModal/components/ModalTopBar.jsx index 053f89f3..6eeef96d 100644 --- a/src/components/Elements/MainModal/components/ModalTopBar.jsx +++ b/src/components/Elements/MainModal/components/ModalTopBar.jsx @@ -1,4 +1,5 @@ import variables from 'config/variables'; +import { useTranslation } from 'contexts/TranslationContext'; import { MdClose, MdChevronRight, MdArrowBack, MdArrowForward } from 'react-icons/md'; import { Tooltip, Button } from 'components/Elements'; import { NAVBAR_BUTTONS } from '../constants/tabConfig'; @@ -31,9 +32,11 @@ function ModalTopBar({ canGoBack, canGoForward, }) { - // Get the current tab label + const { languagecode } = useTranslation(); + + // Get the current tab label (uses languagecode to re-evaluate on language change) const currentTabButton = NAVBAR_BUTTONS.find(({ tab }) => tab === currentTab); - const currentTabLabel = currentTabButton + const currentTabLabel = languagecode && currentTabButton ? variables.getMessage(currentTabButton.messageKey) : ''; diff --git a/src/components/Form/Settings/Radio/Radio.jsx b/src/components/Form/Settings/Radio/Radio.jsx index f3f5697d..595371bc 100644 --- a/src/components/Form/Settings/Radio/Radio.jsx +++ b/src/components/Form/Settings/Radio/Radio.jsx @@ -26,6 +26,20 @@ const Radio = memo((props) => { if (localStorage.getItem('tabName') === variables.getMessage('tabname')) { localStorage.setItem('tabName', translations[newValue.replace('-', '_')].tabname); } + + // Emit language change event for instant update + localStorage.setItem(props.name, newValue); + EventBus.emit('languageChange', { language: newValue }); + setValue(newValue); + + variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`); + + if (props.onChange) { + props.onChange(newValue); + } + + EventBus.emit('refresh', props.category); + return; } localStorage.setItem(props.name, newValue); diff --git a/src/contexts/README.md b/src/contexts/README.md new file mode 100644 index 00000000..4b224966 --- /dev/null +++ b/src/contexts/README.md @@ -0,0 +1,43 @@ +# Translation System + +## Using Reactive Translations + +The app now supports instant language switching without page refresh! + +### For new components + +Use the `useMessage` hook for reactive translations: + +```jsx +import { useMessage } from 'contexts/TranslationContext'; + +function MyComponent() { + const title = useMessage('modals.main.title'); + const description = useMessage('modals.main.description'); + + return ( +
+

{title}

+

{description}

+
+ ); +} +``` + +### For existing components + +The old `variables.getMessage()` method still works but won't automatically update when language changes. Gradually migrate to `useMessage` for components that display translations prominently. + +### How it works + +1. `TranslationProvider` wraps the app and listens for language change events +2. When language is changed via the Radio component, it emits a `languageChange` event +3. The provider updates the i18n instance and triggers re-renders +4. Components using `useMessage` automatically update to show new translations + +### Benefits + +- No page refresh needed +- Minimal performance impact (translations already loaded) +- Backward compatible with existing code +- Smooth user experience diff --git a/src/contexts/TranslationContext.jsx b/src/contexts/TranslationContext.jsx new file mode 100644 index 00000000..3ef09a26 --- /dev/null +++ b/src/contexts/TranslationContext.jsx @@ -0,0 +1,63 @@ +import { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { initTranslations, translations } from 'lib/translations'; +import variables from 'config/variables'; +import EventBus from 'utils/eventbus'; + +const TranslationContext = createContext(); + +export function TranslationProvider({ children, initialLanguage }) { + const [languagecode, setLanguagecode] = useState(initialLanguage); + + const changeLanguage = useCallback((newLanguage) => { + // Update the i18n instance + variables.language = initTranslations(newLanguage); + variables.languagecode = newLanguage; + document.documentElement.lang = newLanguage.replace('_', '-'); + + // Update tab name if it's still the default + if (localStorage.getItem('tabName') === variables.getMessage('tabname')) { + const newTabName = translations[newLanguage.replace('-', '_')]?.tabname || variables.getMessage('tabname'); + localStorage.setItem('tabName', newTabName); + document.title = newTabName; + } + + // Update state to trigger re-render + setLanguagecode(newLanguage); + }, []); + + useEffect(() => { + const handleLanguageChange = (data) => { + if (data?.language) { + changeLanguage(data.language); + } + }; + + EventBus.on('languageChange', handleLanguageChange); + + return () => { + EventBus.off('languageChange', handleLanguageChange); + }; + }, [changeLanguage]); + + return ( + + {children} + + ); +} + +export function useTranslation() { + const context = useContext(TranslationContext); + if (!context) { + throw new Error('useTranslation must be used within a TranslationProvider'); + } + return context; +} + +// Hook for reactive translations - triggers re-render when language changes +export function useMessage(key, optional = {}) { + const { languagecode } = useTranslation(); + // The languagecode dependency ensures this hook re-evaluates when language changes + // This is intentional - we use it to trigger re-renders even though it's not directly used + return languagecode ? variables.getMessage(key, optional) : ''; +} diff --git a/src/contexts/index.js b/src/contexts/index.js new file mode 100644 index 00000000..92d557f6 --- /dev/null +++ b/src/contexts/index.js @@ -0,0 +1 @@ +export { TranslationProvider, useTranslation, useMessage } from './TranslationContext'; diff --git a/src/features/misc/sections/Language.jsx b/src/features/misc/sections/Language.jsx index 9b548e64..20ecfc46 100644 --- a/src/features/misc/sections/Language.jsx +++ b/src/features/misc/sections/Language.jsx @@ -1,5 +1,6 @@ import variables from 'config/variables'; import { useState, useEffect, useRef } from 'react'; +import { useMessage } from 'contexts/TranslationContext'; import { MdOutlineOpenInNew } from 'react-icons/md'; @@ -8,9 +9,14 @@ import { Radio } from 'components/Form/Settings'; import languages from '@/i18n/languages.json'; const LanguageOptions = () => { + const loadingText = useMessage('modals.main.loading'); + const offlineText = useMessage('modals.main.marketplace.offline.description'); + const languageTitle = useMessage('modals.main.settings.sections.language.title'); + const quoteTitle = useMessage('modals.main.settings.sections.language.quote'); + const [quoteLanguages, setQuoteLanguages] = useState([ { - name: variables.getMessage('modals.main.loading'), + name: loadingText, value: 'loading', }, ]); @@ -18,33 +24,39 @@ const LanguageOptions = () => { const controllerRef = useRef(new AbortController()); const getquoteLanguages = async () => { - const data = await ( - await fetch(variables.constants.API_URL + '/quotes/languages', { - signal: controllerRef.current.signal, - }) - ).json(); + try { + const data = await ( + await fetch(variables.constants.API_URL + '/quotes/languages', { + signal: controllerRef.current.signal, + }) + ).json(); - if (controllerRef.current.signal.aborted === true) { - return; + if (controllerRef.current.signal.aborted === true) { + return; + } + + const fetchedQuoteLanguages = data.map((language) => { + return { + name: languages.find((l) => l.value === language.name) + ? languages.find((l) => l.value === language.name).name + : 'English', + value: language, + }; + }); + + setQuoteLanguages(fetchedQuoteLanguages); + } catch (error) { + if (error.name !== 'AbortError') { + console.error('Failed to fetch quote languages:', error); + } } - - const fetchedQuoteLanguages = data.map((language) => { - return { - name: languages.find((l) => l.value === language.name) - ? languages.find((l) => l.value === language.name).name - : 'English', - value: language, - }; - }); - - setQuoteLanguages(fetchedQuoteLanguages); }; useEffect(() => { if (navigator.onLine === false || localStorage.getItem('offlineMode') === 'true') { setQuoteLanguages([ { - name: variables.getMessage('modals.main.marketplace.offline.description'), + name: offlineText, value: 'loading', }, ]); @@ -53,17 +65,18 @@ const LanguageOptions = () => { getquoteLanguages(); + const controller = controllerRef.current; return () => { // stop making requests - controllerRef.current.abort(); + controller.abort(); }; - }, []); + }, [offlineText]); return ( <>
- {variables.getMessage('modals.main.settings.sections.language.title')} + {languageTitle}
{
- {variables.getMessage('modals.main.settings.sections.language.quote')} + {quoteTitle}
+ sections.map(section => ({ + ...section, + translatedLabel: variables.getMessage(section.label) + })), + [languagecode] + ); + return ( props.changeTab(type)} @@ -101,8 +113,8 @@ function Settings(props) { navigationTrigger={props.navigationTrigger} sections={sections} > - {sections.map(({ label, name, component: Component }) => ( -
+ {translatedSections.map(({ label, name, component: Component, translatedLabel }) => ( +
{ EventBus.on('refresh', (data) => { - if (data === 'navbar' || data === 'background') { + if (data === 'navbar' || data === 'background' || data === 'language') { setRefreshOption(localStorage.getItem('refreshOption')); updateRefreshText(); } }); updateRefreshText(); - }, []); + }, [languagecode]); function updateRefreshText() { let text; diff --git a/src/features/welcome/components/Sections/ChooseLanguage.jsx b/src/features/welcome/components/Sections/ChooseLanguage.jsx index ddb1efc8..5c77c5fe 100644 --- a/src/features/welcome/components/Sections/ChooseLanguage.jsx +++ b/src/features/welcome/components/Sections/ChooseLanguage.jsx @@ -1,18 +1,22 @@ import variables from 'config/variables'; import { MdOutlineOpenInNew } from 'react-icons/md'; import languages from '@/i18n/languages.json'; +import { useMessage } from 'contexts/TranslationContext'; import { Radio } from 'components/Form/Settings'; import { Header, Content } from '../Layout'; function ChooseLanguage() { + const title = useMessage('modals.welcome.sections.language.title'); + const description = useMessage('modals.welcome.sections.language.description'); + return (
- {variables.getMessage('modals.welcome.sections.language.description')}{' '} + {description}{' '} { '@': path.resolve(__dirname, './src'), i18n: path.resolve(__dirname, './src/i18n'), components: path.resolve(__dirname, './src/components'), + contexts: path.resolve(__dirname, './src/contexts'), assets: path.resolve(__dirname, './src/assets'), config: path.resolve(__dirname, './src/config'), features: path.resolve(__dirname, './src/features'),