mirror of
https://github.com/mue/mue.git
synced 2026-07-18 14:34:12 +02:00
feat(translation): implement reactive translation system with context and hooks
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<TranslationProvider initialLanguage={languagecode}>
|
||||
{showBackground && <Background />}
|
||||
<ToastContainer
|
||||
position="top-center"
|
||||
@@ -68,7 +71,7 @@ const App = () => {
|
||||
<Widgets />
|
||||
<Modals />
|
||||
</div>
|
||||
</>
|
||||
</TranslationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
: '';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
43
src/contexts/README.md
Normal file
43
src/contexts/README.md
Normal file
@@ -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 (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
63
src/contexts/TranslationContext.jsx
Normal file
63
src/contexts/TranslationContext.jsx
Normal file
@@ -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 (
|
||||
<TranslationContext.Provider value={{ languagecode, changeLanguage }}>
|
||||
{children}
|
||||
</TranslationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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) : '';
|
||||
}
|
||||
1
src/contexts/index.js
Normal file
1
src/contexts/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { TranslationProvider, useTranslation, useMessage } from './TranslationContext';
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="modalHeader">
|
||||
<span className="mainTitle">
|
||||
{variables.getMessage('modals.main.settings.sections.language.title')}
|
||||
{languageTitle}
|
||||
</span>
|
||||
<div className="headerActions">
|
||||
<a
|
||||
@@ -81,7 +94,7 @@ const LanguageOptions = () => {
|
||||
<Radio name="language" options={languages} element=".other" />
|
||||
</div>
|
||||
<span className="mainTitle">
|
||||
{variables.getMessage('modals.main.settings.sections.language.quote')}
|
||||
{quoteTitle}
|
||||
</span>
|
||||
<div className="languageSettings">
|
||||
<Radio
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'contexts/TranslationContext';
|
||||
|
||||
import Tabs from 'components/Elements/MainModal/backend/Tabs';
|
||||
|
||||
@@ -89,6 +90,17 @@ const sections = [
|
||||
];
|
||||
|
||||
function Settings(props) {
|
||||
const { languagecode } = useTranslation();
|
||||
|
||||
// Recalculate section labels when language changes
|
||||
const translatedSections = useMemo(() =>
|
||||
sections.map(section => ({
|
||||
...section,
|
||||
translatedLabel: variables.getMessage(section.label)
|
||||
})),
|
||||
[languagecode]
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
changeTab={(type) => props.changeTab(type)}
|
||||
@@ -101,8 +113,8 @@ function Settings(props) {
|
||||
navigationTrigger={props.navigationTrigger}
|
||||
sections={sections}
|
||||
>
|
||||
{sections.map(({ label, name, component: Component }) => (
|
||||
<div key={name} label={variables.getMessage(label)} name={name}>
|
||||
{translatedSections.map(({ label, name, component: Component, translatedLabel }) => (
|
||||
<div key={name} label={translatedLabel} name={name}>
|
||||
<Component
|
||||
currentSubSection={props.currentSubSection}
|
||||
onSubSectionChange={props.onSubSectionChange}
|
||||
|
||||
@@ -3,21 +3,23 @@ import variables from 'config/variables';
|
||||
import { MdRefresh } from 'react-icons/md';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import EventBus from 'utils/eventbus';
|
||||
import { useTranslation } from 'contexts/TranslationContext';
|
||||
|
||||
function Refresh() {
|
||||
const { languagecode } = useTranslation();
|
||||
const [refreshText, setRefreshText] = useState('');
|
||||
const [refreshOption, setRefreshOption] = useState(localStorage.getItem('refreshOption') || '');
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
|
||||
@@ -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 (
|
||||
<Content>
|
||||
<Header
|
||||
title={variables.getMessage('modals.welcome.sections.language.title')}
|
||||
title={title}
|
||||
subtitle={
|
||||
<>
|
||||
{variables.getMessage('modals.welcome.sections.language.description')}{' '}
|
||||
{description}{' '}
|
||||
<a
|
||||
href={variables.constants.TRANSLATIONS_URL}
|
||||
className="link"
|
||||
|
||||
Reference in New Issue
Block a user