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'),