mirror of
https://github.com/mue/mue.git
synced 2026-06-08 22:18:40 +02:00
feat(translation): refactor translation system to use useT hook for reactive updates
This commit is contained in:
@@ -1,21 +1,20 @@
|
|||||||
import variables from 'config/variables';
|
|
||||||
import { memo, useState, useEffect } from 'react';
|
import { memo, useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'contexts/TranslationContext';
|
import { useT } from 'contexts/TranslationContext';
|
||||||
import { getIconComponent, DIVIDER_LABELS } from '../constants/tabConfig';
|
import { getIconComponent, DIVIDER_LABELS } from '../constants/tabConfig';
|
||||||
|
|
||||||
function Tab({ label, currentTab, onClick, navbarTab }) {
|
function Tab({ label, currentTab, onClick, navbarTab }) {
|
||||||
const { languagecode } = useTranslation();
|
const t = useT();
|
||||||
const [isExperimental, setIsExperimental] = useState(true);
|
const [isExperimental, setIsExperimental] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsExperimental(localStorage.getItem('experimental') !== 'false');
|
setIsExperimental(localStorage.getItem('experimental') !== 'false');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Get the icon component for this label
|
// Get the icon component for this label (label is already translated)
|
||||||
const IconComponent = getIconComponent(label, variables);
|
const IconComponent = getIconComponent(label, { getMessage: t });
|
||||||
|
|
||||||
// Determine if this label should have a divider after it
|
// Determine if this label should have a divider after it
|
||||||
const hasDivider = DIVIDER_LABELS.some((key) => variables.getMessage(key) === label);
|
const hasDivider = DIVIDER_LABELS.some((key) => t(key) === label);
|
||||||
|
|
||||||
// Build className
|
// Build className
|
||||||
const baseClass = navbarTab ? 'navbar-item' : 'tab-list-item';
|
const baseClass = navbarTab ? 'navbar-item' : 'tab-list-item';
|
||||||
@@ -23,8 +22,7 @@ function Tab({ label, currentTab, onClick, navbarTab }) {
|
|||||||
const className = `${baseClass}${currentTab === label ? ` ${activeClass}` : ''}`;
|
const className = `${baseClass}${currentTab === label ? ` ${activeClass}` : ''}`;
|
||||||
|
|
||||||
// Hide experimental tab if experimental mode is disabled
|
// Hide experimental tab if experimental mode is disabled
|
||||||
const isExperimentalTab =
|
const isExperimentalTab = label === t('modals.main.settings.sections.experimental.title');
|
||||||
label === variables.getMessage('modals.main.settings.sections.experimental.title');
|
|
||||||
if (isExperimentalTab && !isExperimental) {
|
if (isExperimentalTab && !isExperimental) {
|
||||||
return <hr />;
|
return <hr />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import variables from 'config/variables';
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'contexts/TranslationContext';
|
import { useT } from 'contexts/TranslationContext';
|
||||||
|
import variables from 'config/variables';
|
||||||
import Tab from './Tab';
|
import Tab from './Tab';
|
||||||
import ReminderInfo from '../components/ReminderInfo';
|
import ReminderInfo from '../components/ReminderInfo';
|
||||||
import ErrorBoundary from '../../../../features/misc/modals/ErrorBoundary';
|
import ErrorBoundary from '../../../../features/misc/modals/ErrorBoundary';
|
||||||
@@ -16,7 +16,7 @@ const Tabs = ({
|
|||||||
navigationTrigger,
|
navigationTrigger,
|
||||||
sections,
|
sections,
|
||||||
}) => {
|
}) => {
|
||||||
const { languagecode } = useTranslation();
|
const t = useT();
|
||||||
|
|
||||||
// Find initial section from deep link if available
|
// Find initial section from deep link if available
|
||||||
const getInitialSection = () => {
|
const getInitialSection = () => {
|
||||||
@@ -24,7 +24,7 @@ const Tabs = ({
|
|||||||
const section = sections.find((s) => s.name === deepLinkData.section);
|
const section = sections.find((s) => s.name === deepLinkData.section);
|
||||||
if (section) {
|
if (section) {
|
||||||
return {
|
return {
|
||||||
label: variables.getMessage(section.label),
|
label: t(section.label),
|
||||||
name: section.name,
|
name: section.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -66,23 +66,23 @@ const Tabs = ({
|
|||||||
if (sections && currentName) {
|
if (sections && currentName) {
|
||||||
const section = sections.find((s) => s.name === currentName);
|
const section = sections.find((s) => s.name === currentName);
|
||||||
if (section) {
|
if (section) {
|
||||||
const newLabel = variables.getMessage(section.label);
|
const newLabel = t(section.label);
|
||||||
setCurrentTab(newLabel);
|
setCurrentTab(newLabel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [languagecode]);
|
}, [t, sections, currentName]);
|
||||||
|
|
||||||
// Handle navigation trigger for settings sections (popstate)
|
// Handle navigation trigger for settings sections (popstate)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (navigationTrigger?.type === 'settings-section' && sections) {
|
if (navigationTrigger?.type === 'settings-section' && sections) {
|
||||||
const section = sections.find((s) => s.name === navigationTrigger.data);
|
const section = sections.find((s) => s.name === navigationTrigger.data);
|
||||||
if (section) {
|
if (section) {
|
||||||
const label = variables.getMessage(section.label);
|
const label = t(section.label);
|
||||||
setCurrentTab(label);
|
setCurrentTab(label);
|
||||||
setCurrentName(section.name);
|
setCurrentName(section.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [navigationTrigger, sections]);
|
}, [navigationTrigger, sections, t]);
|
||||||
|
|
||||||
// Reset to first tab when requested
|
// Reset to first tab when requested
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import variables from 'config/variables';
|
import { useT } from 'contexts/TranslationContext';
|
||||||
import { useTranslation } from 'contexts/TranslationContext';
|
|
||||||
import { MdClose, MdChevronRight, MdArrowBack, MdArrowForward } from 'react-icons/md';
|
import { MdClose, MdChevronRight, MdArrowBack, MdArrowForward } from 'react-icons/md';
|
||||||
import { Tooltip, Button } from 'components/Elements';
|
import { Tooltip, Button } from 'components/Elements';
|
||||||
import { NAVBAR_BUTTONS } from '../constants/tabConfig';
|
import { NAVBAR_BUTTONS } from '../constants/tabConfig';
|
||||||
@@ -32,13 +31,11 @@ function ModalTopBar({
|
|||||||
canGoBack,
|
canGoBack,
|
||||||
canGoForward,
|
canGoForward,
|
||||||
}) {
|
}) {
|
||||||
const { languagecode } = useTranslation();
|
const t = useT();
|
||||||
|
|
||||||
// Get the current tab label (uses languagecode to re-evaluate on language change)
|
// Get the current tab label
|
||||||
const currentTabButton = NAVBAR_BUTTONS.find(({ tab }) => tab === currentTab);
|
const currentTabButton = NAVBAR_BUTTONS.find(({ tab }) => tab === currentTab);
|
||||||
const currentTabLabel = languagecode && currentTabButton
|
const currentTabLabel = currentTabButton ? t(currentTabButton.messageKey) : '';
|
||||||
? variables.getMessage(currentTabButton.messageKey)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Utility function to get translated sub-section label
|
// Utility function to get translated sub-section label
|
||||||
const getSubSectionLabel = (subSection, sectionName) => {
|
const getSubSectionLabel = (subSection, sectionName) => {
|
||||||
@@ -46,7 +43,7 @@ function ModalTopBar({
|
|||||||
|
|
||||||
// Use the same translation pattern as the section components
|
// Use the same translation pattern as the section components
|
||||||
const translationKey = `modals.main.settings.sections.${sectionName}.${subSection}.title`;
|
const translationKey = `modals.main.settings.sections.${sectionName}.${subSection}.title`;
|
||||||
const translated = variables.getMessage(translationKey);
|
const translated = t(translationKey);
|
||||||
|
|
||||||
// If translation key is returned as-is or empty, it means translation doesn't exist
|
// If translation key is returned as-is or empty, it means translation doesn't exist
|
||||||
// Fall back to capitalized sub-section name
|
// Fall back to capitalized sub-section name
|
||||||
@@ -107,7 +104,7 @@ function ModalTopBar({
|
|||||||
const categoryKey = MARKETPLACE_TYPE_TO_KEY[productView.type];
|
const categoryKey = MARKETPLACE_TYPE_TO_KEY[productView.type];
|
||||||
if (categoryKey) {
|
if (categoryKey) {
|
||||||
breadcrumbPath.push({
|
breadcrumbPath.push({
|
||||||
label: variables.getMessage(categoryKey),
|
label: t(categoryKey),
|
||||||
onClick: productView.onBack || null,
|
onClick: productView.onBack || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -214,11 +211,11 @@ function ModalTopBar({
|
|||||||
onClick={() => onTabChange(tab)}
|
onClick={() => onTabChange(tab)}
|
||||||
active={currentTab === tab}
|
active={currentTab === tab}
|
||||||
icon={<Icon />}
|
icon={<Icon />}
|
||||||
label={variables.getMessage(messageKey)}
|
label={t(messageKey)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Tooltip title={variables.getMessage('modals.welcome.buttons.close')} key="closeTooltip">
|
<Tooltip title={t('modals.welcome.buttons.close')} key="closeTooltip">
|
||||||
<span className="closeModal" onClick={onClose}>
|
<span className="closeModal" onClick={onClose}>
|
||||||
<MdClose />
|
<MdClose />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import variables from 'config/variables';
|
import variables from 'config/variables';
|
||||||
import { memo, useState, useCallback } from 'react';
|
import { memo, useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'contexts/TranslationContext';
|
||||||
import {
|
import {
|
||||||
Radio as RadioUI,
|
Radio as RadioUI,
|
||||||
RadioGroup,
|
RadioGroup,
|
||||||
@@ -9,9 +10,9 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import EventBus from 'utils/eventbus';
|
import EventBus from 'utils/eventbus';
|
||||||
import { translations } from 'lib/translations';
|
|
||||||
|
|
||||||
const Radio = memo((props) => {
|
const Radio = memo((props) => {
|
||||||
|
const { changeLanguage } = useTranslation();
|
||||||
const [value, setValue] = useState(localStorage.getItem(props.name));
|
const [value, setValue] = useState(localStorage.getItem(props.name));
|
||||||
|
|
||||||
const handleChange = useCallback(async (e) => {
|
const handleChange = useCallback(async (e) => {
|
||||||
@@ -22,14 +23,8 @@ const Radio = memo((props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (props.name === 'language') {
|
if (props.name === 'language') {
|
||||||
// old tab name
|
// Use context to change language directly - no EventBus needed
|
||||||
if (localStorage.getItem('tabName') === variables.getMessage('tabname')) {
|
changeLanguage(newValue);
|
||||||
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);
|
setValue(newValue);
|
||||||
|
|
||||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||||
@@ -59,7 +54,7 @@ const Radio = memo((props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EventBus.emit('refresh', props.category);
|
EventBus.emit('refresh', props.category);
|
||||||
}, [value, props]);
|
}, [value, props, changeLanguage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormControl component="fieldset">
|
<FormControl component="fieldset">
|
||||||
|
|||||||
@@ -1,43 +1,96 @@
|
|||||||
# Translation System
|
# Translation System
|
||||||
|
|
||||||
## Using Reactive Translations
|
## Refactored Translation System ✨
|
||||||
|
|
||||||
The app now supports instant language switching without page refresh!
|
The app now has a robust, centralized translation system that updates instantly without page refresh!
|
||||||
|
|
||||||
### For new components
|
### Using Translations (New API)
|
||||||
|
|
||||||
Use the `useMessage` hook for reactive translations:
|
**Recommended approach** - Use the `useT()` hook:
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
import { useMessage } from 'contexts/TranslationContext';
|
import { useT } from 'contexts/TranslationContext';
|
||||||
|
|
||||||
function MyComponent() {
|
function MyComponent() {
|
||||||
const title = useMessage('modals.main.title');
|
const t = useT();
|
||||||
const description = useMessage('modals.main.description');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{title}</h1>
|
<h1>{t('modals.main.title')}</h1>
|
||||||
<p>{description}</p>
|
<p>{t('modals.main.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### For existing components
|
**Alternative** - Use the full hook:
|
||||||
|
|
||||||
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.
|
```jsx
|
||||||
|
import { useTranslation } from 'contexts/TranslationContext';
|
||||||
|
|
||||||
### How it works
|
function MyComponent() {
|
||||||
|
const { t, language, changeLanguage } = useTranslation();
|
||||||
|
|
||||||
1. `TranslationProvider` wraps the app and listens for language change events
|
return (
|
||||||
2. When language is changed via the Radio component, it emits a `languageChange` event
|
<div>
|
||||||
3. The provider updates the i18n instance and triggers re-renders
|
<h1>{t('modals.main.title')}</h1>
|
||||||
4. Components using `useMessage` automatically update to show new translations
|
<button onClick={() => changeLanguage('es')}>Español</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
|
||||||
|
The old `variables.getMessage()` method still works and is automatically reactive:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// This still works and updates on language change
|
||||||
|
const title = variables.getMessage('modals.main.title');
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows gradual migration of components.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **TranslationProvider** wraps the app and manages the i18n instance
|
||||||
|
2. **Single source of truth** - All translation logic is centralized in the context
|
||||||
|
3. **Automatic reactivity** - Components using `t()` automatically re-render on language change
|
||||||
|
4. **Backward compatible** - `variables.getMessage()` is updated to use the context internally
|
||||||
|
5. **No EventBus needed** - Direct context updates for language changes
|
||||||
|
|
||||||
### Benefits
|
### Benefits
|
||||||
|
|
||||||
- No page refresh needed
|
✅ **Instant updates** - No page refresh needed
|
||||||
- Minimal performance impact (translations already loaded)
|
✅ **Single API** - One consistent way to translate
|
||||||
- Backward compatible with existing code
|
✅ **Automatic re-renders** - React handles updates efficiently
|
||||||
- Smooth user experience
|
✅ **Minimal performance impact** - Translations pre-loaded, only switching active language
|
||||||
|
✅ **Type-safe ready** - Easy to add TypeScript support later
|
||||||
|
✅ **Clean architecture** - Centralized translation logic
|
||||||
|
|
||||||
|
### Migration Guide
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```jsx
|
||||||
|
const title = variables.getMessage('modals.main.title');
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```jsx
|
||||||
|
const t = useT();
|
||||||
|
const title = t('modals.main.title');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components Updated
|
||||||
|
|
||||||
|
Core navigation and UI:
|
||||||
|
- ✅ TranslationContext (centralized API)
|
||||||
|
- ✅ Radio component (language selector)
|
||||||
|
- ✅ Tab, Tabs (sidebar navigation)
|
||||||
|
- ✅ ModalTopBar (breadcrumbs)
|
||||||
|
- ✅ Settings view
|
||||||
|
- ✅ Language section
|
||||||
|
- ✅ Refresh button
|
||||||
|
- ✅ Welcome modal
|
||||||
|
|
||||||
|
All other components continue to work via backward compatibility.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { initTranslations, translations } from 'lib/translations';
|
import { initTranslations, translations } from 'lib/translations';
|
||||||
import variables from 'config/variables';
|
import variables from 'config/variables';
|
||||||
import EventBus from 'utils/eventbus';
|
import EventBus from 'utils/eventbus';
|
||||||
@@ -6,25 +6,51 @@ import EventBus from 'utils/eventbus';
|
|||||||
const TranslationContext = createContext();
|
const TranslationContext = createContext();
|
||||||
|
|
||||||
export function TranslationProvider({ children, initialLanguage }) {
|
export function TranslationProvider({ children, initialLanguage }) {
|
||||||
const [languagecode, setLanguagecode] = useState(initialLanguage);
|
const [currentLanguage, setCurrentLanguage] = useState(initialLanguage);
|
||||||
|
const i18nInstance = useRef(null);
|
||||||
|
|
||||||
|
// Initialize i18n instance once
|
||||||
|
useEffect(() => {
|
||||||
|
i18nInstance.current = initTranslations(currentLanguage);
|
||||||
|
variables.language = i18nInstance.current;
|
||||||
|
variables.languagecode = currentLanguage;
|
||||||
|
document.documentElement.lang = currentLanguage.replace('_', '-');
|
||||||
|
}, [currentLanguage]);
|
||||||
|
|
||||||
|
// Change language function
|
||||||
const changeLanguage = useCallback((newLanguage) => {
|
const changeLanguage = useCallback((newLanguage) => {
|
||||||
// Update the i18n instance
|
// Update the i18n instance
|
||||||
variables.language = initTranslations(newLanguage);
|
i18nInstance.current = initTranslations(newLanguage);
|
||||||
|
variables.language = i18nInstance.current;
|
||||||
variables.languagecode = newLanguage;
|
variables.languagecode = newLanguage;
|
||||||
document.documentElement.lang = newLanguage.replace('_', '-');
|
document.documentElement.lang = newLanguage.replace('_', '-');
|
||||||
|
|
||||||
// Update tab name if it's still the default
|
// Update tab name if it's still the default
|
||||||
if (localStorage.getItem('tabName') === variables.getMessage('tabname')) {
|
const currentTabName = localStorage.getItem('tabName');
|
||||||
const newTabName = translations[newLanguage.replace('-', '_')]?.tabname || variables.getMessage('tabname');
|
const oldDefaultTabName = i18nInstance.current?.getMessage(currentLanguage, 'tabname');
|
||||||
|
|
||||||
|
if (currentTabName === oldDefaultTabName || !currentTabName) {
|
||||||
|
const newTabName = translations[newLanguage.replace('-', '_')]?.tabname ||
|
||||||
|
i18nInstance.current?.getMessage(newLanguage, 'tabname') ||
|
||||||
|
'Mue';
|
||||||
localStorage.setItem('tabName', newTabName);
|
localStorage.setItem('tabName', newTabName);
|
||||||
document.title = newTabName;
|
document.title = newTabName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state to trigger re-render
|
// Update language in localStorage
|
||||||
setLanguagecode(newLanguage);
|
localStorage.setItem('language', newLanguage);
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
// Update state to trigger re-render
|
||||||
|
setCurrentLanguage(newLanguage);
|
||||||
|
}, [currentLanguage]);
|
||||||
|
|
||||||
|
// Single translation function - the main API
|
||||||
|
const t = useCallback((key, optional = {}) => {
|
||||||
|
if (!i18nInstance.current) return key;
|
||||||
|
return i18nInstance.current.getMessage(currentLanguage, key, optional);
|
||||||
|
}, [currentLanguage]);
|
||||||
|
|
||||||
|
// Listen for EventBus language change events (for backward compatibility)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleLanguageChange = (data) => {
|
const handleLanguageChange = (data) => {
|
||||||
if (data?.language) {
|
if (data?.language) {
|
||||||
@@ -39,8 +65,20 @@ export function TranslationProvider({ children, initialLanguage }) {
|
|||||||
};
|
};
|
||||||
}, [changeLanguage]);
|
}, [changeLanguage]);
|
||||||
|
|
||||||
|
// Update variables.getMessage for backward compatibility
|
||||||
|
useEffect(() => {
|
||||||
|
variables.getMessage = (key, optional = {}) => t(key, optional);
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
language: currentLanguage,
|
||||||
|
languagecode: currentLanguage, // Alias for backward compatibility
|
||||||
|
changeLanguage,
|
||||||
|
t,
|
||||||
|
}), [currentLanguage, changeLanguage, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TranslationContext.Provider value={{ languagecode, changeLanguage }}>
|
<TranslationContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</TranslationContext.Provider>
|
</TranslationContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -54,10 +92,8 @@ export function useTranslation() {
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook for reactive translations - triggers re-render when language changes
|
// Convenience hook - just returns the t function
|
||||||
export function useMessage(key, optional = {}) {
|
export function useT() {
|
||||||
const { languagecode } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// The languagecode dependency ensures this hook re-evaluates when language changes
|
return t;
|
||||||
// This is intentional - we use it to trigger re-renders even though it's not directly used
|
|
||||||
return languagecode ? variables.getMessage(key, optional) : '';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { TranslationProvider, useTranslation, useMessage } from './TranslationContext';
|
export { TranslationProvider, useTranslation, useT } from './TranslationContext';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import variables from 'config/variables';
|
import variables from 'config/variables';
|
||||||
import { useState, memo } from 'react';
|
import { useState, memo } from 'react';
|
||||||
|
import { useT } from 'contexts';
|
||||||
import Favourite from './Favourite';
|
import Favourite from './Favourite';
|
||||||
import {
|
import {
|
||||||
MdInfo,
|
MdInfo,
|
||||||
@@ -54,6 +55,7 @@ const downloadImage = async (info) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function PhotoInformation({ info, url, api }) {
|
function PhotoInformation({ info, url, api }) {
|
||||||
|
const t = useT();
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
const [height, setHeight] = useState(0);
|
const [height, setHeight] = useState(0);
|
||||||
const [usePhotoMap, setPhotoMap] = useState(false);
|
const [usePhotoMap, setPhotoMap] = useState(false);
|
||||||
@@ -64,7 +66,7 @@ function PhotoInformation({ info, url, api }) {
|
|||||||
const [shareModal, openShareModal] = useState(false);
|
const [shareModal, openShareModal] = useState(false);
|
||||||
const [excludeModal, openExcludeModal] = useState(false);
|
const [excludeModal, openExcludeModal] = useState(false);
|
||||||
const [favouriteTooltipText, setFavouriteTooltipText] = useState(
|
const [favouriteTooltipText, setFavouriteTooltipText] = useState(
|
||||||
variables.getMessage('widgets.quote.favourite'),
|
t('widgets.quote.favourite'),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (info.hidden === true || !info.credit) {
|
if (info.hidden === true || !info.credit) {
|
||||||
@@ -72,7 +74,7 @@ function PhotoInformation({ info, url, api }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let credit = info.credit;
|
let credit = info.credit;
|
||||||
let photo = variables.getMessage('widgets.background.credit');
|
let photo = t('widgets.background.credit');
|
||||||
|
|
||||||
// unsplash credit
|
// unsplash credit
|
||||||
if (info.photographerURL && info.photographerURL !== '' && !info.offline && api) {
|
if (info.photographerURL && info.photographerURL !== '' && !info.offline && api) {
|
||||||
@@ -142,31 +144,31 @@ function PhotoInformation({ info, url, api }) {
|
|||||||
return (
|
return (
|
||||||
<div className="extra-content">
|
<div className="extra-content">
|
||||||
{info.location && info.location !== 'N/A' ? (
|
{info.location && info.location !== 'N/A' ? (
|
||||||
<div className="row" title={variables.getMessage('widgets.background.location')}>
|
<div className="row" title={t('widgets.background.location')}>
|
||||||
<MdLocationOn />
|
<MdLocationOn />
|
||||||
<span id="infoLocation">{info.location}</span>
|
<span id="infoLocation">{info.location}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{info.camera && info.camera !== 'N/A' ? (
|
{info.camera && info.camera !== 'N/A' ? (
|
||||||
<div className="row" title={variables.getMessage('widgets.background.camera')}>
|
<div className="row" title={t('widgets.background.camera')}>
|
||||||
<MdPhotoCamera />
|
<MdPhotoCamera />
|
||||||
<span id="infoCamera">{info.camera}</span>
|
<span id="infoCamera">{info.camera}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="row" title={variables.getMessage('widgets.background.resolution')}>
|
<div className="row" title={t('widgets.background.resolution')}>
|
||||||
<Resolution />
|
<Resolution />
|
||||||
<span id="infoResolution">
|
<span id="infoResolution">
|
||||||
{width}x{height}
|
{width}x{height}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{info.category && (
|
{info.category && (
|
||||||
<div className="row" title={variables.getMessage('widgets.background.category')}>
|
<div className="row" title={t('widgets.background.category')}>
|
||||||
<Category />
|
<Category />
|
||||||
<span id="infoCategory">{info.category[0].toUpperCase() + info.category.slice(1)}</span>
|
<span id="infoCategory">{info.category[0].toUpperCase() + info.category.slice(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{api && (
|
{api && (
|
||||||
<div className="row" title={variables.getMessage('widgets.background.source')}>
|
<div className="row" title={t('widgets.background.source')}>
|
||||||
<Source />
|
<Source />
|
||||||
<span id="infoSource">
|
<span id="infoSource">
|
||||||
{info.photoURL ? (
|
{info.photoURL ? (
|
||||||
@@ -189,7 +191,7 @@ function PhotoInformation({ info, url, api }) {
|
|||||||
return (
|
return (
|
||||||
<div className="buttons">
|
<div className="buttons">
|
||||||
{!info.offline && (
|
{!info.offline && (
|
||||||
<Tooltip title={variables.getMessage('widgets.quote.share')} key="share" placement="top">
|
<Tooltip title={t('widgets.quote.share')} key="share" placement="top">
|
||||||
<Share onClick={() => openShareModal(true)} />
|
<Share onClick={() => openShareModal(true)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -204,7 +206,7 @@ function PhotoInformation({ info, url, api }) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
{!info.offline && (
|
{!info.offline && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={variables.getMessage('widgets.background.download')}
|
title={t('widgets.background.download')}
|
||||||
key="download"
|
key="download"
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
@@ -213,7 +215,7 @@ function PhotoInformation({ info, url, api }) {
|
|||||||
)}
|
)}
|
||||||
{info.pun && info.category && (
|
{info.pun && info.category && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={variables.getMessage('widgets.background.exclude')}
|
title={t('widgets.background.exclude')}
|
||||||
key="exclude"
|
key="exclude"
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
@@ -227,16 +229,16 @@ function PhotoInformation({ info, url, api }) {
|
|||||||
const UnsplashStats = () => {
|
const UnsplashStats = () => {
|
||||||
return (
|
return (
|
||||||
<div className="unsplashStats">
|
<div className="unsplashStats">
|
||||||
<div title={variables.getMessage('widgets.background.views')}>
|
<div title={t('widgets.background.views')}>
|
||||||
<Views />
|
<Views />
|
||||||
<span>{info.views.toLocaleString()}</span>
|
<span>{info.views.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div title={variables.getMessage('widgets.background.downloads')}>
|
<div title={t('widgets.background.downloads')}>
|
||||||
<Download />
|
<Download />
|
||||||
<span>{info.downloads.toLocaleString()}</span>
|
<span>{info.downloads.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
{info.likes ? (
|
{info.likes ? (
|
||||||
<div title={variables.getMessage('widgets.background.likes')}>
|
<div title={t('widgets.background.likes')}>
|
||||||
<MdFavourite />
|
<MdFavourite />
|
||||||
<span>{info.likes.toLocaleString()}</span>
|
<span>{info.likes.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -366,7 +368,7 @@ function PhotoInformation({ info, url, api }) {
|
|||||||
{(showExtraInfo || other) && excludeModal === false ? (
|
{(showExtraInfo || other) && excludeModal === false ? (
|
||||||
<>
|
<>
|
||||||
<span className="subtitle">
|
<span className="subtitle">
|
||||||
{variables.getMessage('widgets.background.information')}
|
{t('widgets.background.information')}
|
||||||
</span>
|
</span>
|
||||||
<InformationItems />
|
<InformationItems />
|
||||||
<ActionButtons />
|
<ActionButtons />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import variables from 'config/variables';
|
import variables from 'config/variables';
|
||||||
import { nth, convertTimezone } from 'utils/date';
|
import { nth, convertTimezone } from 'utils/date';
|
||||||
import EventBus from 'utils/eventbus';
|
import EventBus from 'utils/eventbus';
|
||||||
|
import { useT } from 'contexts';
|
||||||
import './greeting.scss';
|
import './greeting.scss';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +45,7 @@ const calculateAge = (date) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Greeting = () => {
|
const Greeting = () => {
|
||||||
|
const t = useT();
|
||||||
const [greeting, setGreeting] = useState('');
|
const [greeting, setGreeting] = useState('');
|
||||||
const [display, setDisplay] = useState('block');
|
const [display, setDisplay] = useState('block');
|
||||||
const [fontSize, setFontSize] = useState('1.6em');
|
const [fontSize, setFontSize] = useState('1.6em');
|
||||||
@@ -63,13 +65,13 @@ const Greeting = () => {
|
|||||||
let message;
|
let message;
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case hour < 12:
|
case hour < 12:
|
||||||
message = variables.getMessage('widgets.greeting.morning');
|
message = t('widgets.greeting.morning');
|
||||||
break;
|
break;
|
||||||
case hour < 18:
|
case hour < 18:
|
||||||
message = variables.getMessage('widgets.greeting.afternoon');
|
message = t('widgets.greeting.afternoon');
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
message = variables.getMessage('widgets.greeting.evening');
|
message = t('widgets.greeting.evening');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +105,10 @@ const Greeting = () => {
|
|||||||
|
|
||||||
if (birth.getDate() === now.getDate() && birth.getMonth() === now.getMonth()) {
|
if (birth.getDate() === now.getDate() && birth.getMonth() === now.getMonth()) {
|
||||||
if (localStorage.getItem('birthdayage') === 'true' && calculateAge(birth) !== 0) {
|
if (localStorage.getItem('birthdayage') === 'true' && calculateAge(birth) !== 0) {
|
||||||
const text = variables.getMessage('widgets.greeting.birthday').split(' ');
|
const text = t('widgets.greeting.birthday').split(' ');
|
||||||
message = `${text[0]} ${nth(calculateAge(birth))} ${text[1]}`;
|
message = `${text[0]} ${nth(calculateAge(birth))} ${text[1]}`;
|
||||||
} else {
|
} else {
|
||||||
message = variables.getMessage('widgets.greeting.birthday');
|
message = t('widgets.greeting.birthday');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,7 +151,7 @@ const Greeting = () => {
|
|||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="greeting" ref={greetingRef} style={{ display, fontSize }}>
|
<span className="greeting" ref={greetingRef} style={{ display, fontSize }}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import variables from 'config/variables';
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useMessage } from 'contexts/TranslationContext';
|
import { useT } from 'contexts/TranslationContext';
|
||||||
|
import variables from 'config/variables';
|
||||||
|
|
||||||
import { MdOutlineOpenInNew } from 'react-icons/md';
|
import { MdOutlineOpenInNew } from 'react-icons/md';
|
||||||
|
|
||||||
@@ -9,10 +9,11 @@ import { Radio } from 'components/Form/Settings';
|
|||||||
import languages from '@/i18n/languages.json';
|
import languages from '@/i18n/languages.json';
|
||||||
|
|
||||||
const LanguageOptions = () => {
|
const LanguageOptions = () => {
|
||||||
const loadingText = useMessage('modals.main.loading');
|
const t = useT();
|
||||||
const offlineText = useMessage('modals.main.marketplace.offline.description');
|
const loadingText = t('modals.main.loading');
|
||||||
const languageTitle = useMessage('modals.main.settings.sections.language.title');
|
const offlineText = t('modals.main.marketplace.offline.description');
|
||||||
const quoteTitle = useMessage('modals.main.settings.sections.language.quote');
|
const languageTitle = t('modals.main.settings.sections.language.title');
|
||||||
|
const quoteTitle = t('modals.main.settings.sections.language.quote');
|
||||||
|
|
||||||
const [quoteLanguages, setQuoteLanguages] = useState([
|
const [quoteLanguages, setQuoteLanguages] = useState([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import variables from 'config/variables';
|
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { useTranslation } from 'contexts/TranslationContext';
|
import { useT } from 'contexts/TranslationContext';
|
||||||
|
|
||||||
import Tabs from 'components/Elements/MainModal/backend/Tabs';
|
import Tabs from 'components/Elements/MainModal/backend/Tabs';
|
||||||
|
|
||||||
@@ -90,15 +89,15 @@ const sections = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function Settings(props) {
|
function Settings(props) {
|
||||||
const { languagecode } = useTranslation();
|
const t = useT();
|
||||||
|
|
||||||
// Recalculate section labels when language changes
|
// Recalculate section labels when language changes
|
||||||
const translatedSections = useMemo(() =>
|
const translatedSections = useMemo(() =>
|
||||||
sections.map(section => ({
|
sections.map(section => ({
|
||||||
...section,
|
...section,
|
||||||
translatedLabel: variables.getMessage(section.label)
|
translatedLabel: t(section.label)
|
||||||
})),
|
})),
|
||||||
[languagecode]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import variables from 'config/variables';
|
import variables from 'config/variables';
|
||||||
import { memo, useState, useCallback } from 'react';
|
import { memo, useState, useCallback } from 'react';
|
||||||
|
import { useT } from 'contexts';
|
||||||
|
|
||||||
import { MdCropFree } from 'react-icons/md';
|
import { MdCropFree } from 'react-icons/md';
|
||||||
|
|
||||||
import { Tooltip } from 'components/Elements';
|
import { Tooltip } from 'components/Elements';
|
||||||
|
|
||||||
const Maximise = memo(({ fontSize }) => {
|
const Maximise = memo(({ fontSize }) => {
|
||||||
|
const t = useT();
|
||||||
const [hidden, setHidden] = useState(false);
|
const [hidden, setHidden] = useState(false);
|
||||||
|
|
||||||
const setAttribute = useCallback((blur, brightness, filter) => {
|
const setAttribute = useCallback((blur, brightness, filter) => {
|
||||||
@@ -60,13 +62,13 @@ const Maximise = memo(({ fontSize }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={variables.getMessage('modals.main.settings.sections.background.buttons.view')}
|
title={t('modals.main.settings.sections.background.buttons.view')}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="navbarButton"
|
className="navbarButton"
|
||||||
style={{ fontSize }}
|
style={{ fontSize }}
|
||||||
onClick={maximise}
|
onClick={maximise}
|
||||||
aria-label={variables.getMessage('modals.main.settings.sections.background.buttons.view')}
|
aria-label={t('modals.main.settings.sections.background.buttons.view')}
|
||||||
>
|
>
|
||||||
<MdCropFree className="topicons" />
|
<MdCropFree className="topicons" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,34 +1,39 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import variables from 'config/variables';
|
import { useT } from 'contexts/TranslationContext';
|
||||||
import { MdRefresh } from 'react-icons/md';
|
import { MdRefresh } from 'react-icons/md';
|
||||||
import { Tooltip } from 'components/Elements';
|
import { Tooltip } from 'components/Elements';
|
||||||
import EventBus from 'utils/eventbus';
|
import EventBus from 'utils/eventbus';
|
||||||
import { useTranslation } from 'contexts/TranslationContext';
|
import variables from 'config/variables';
|
||||||
|
|
||||||
function Refresh() {
|
function Refresh() {
|
||||||
const { languagecode } = useTranslation();
|
const t = useT();
|
||||||
const [refreshText, setRefreshText] = useState('');
|
const [refreshText, setRefreshText] = useState('');
|
||||||
const [refreshOption, setRefreshOption] = useState(localStorage.getItem('refreshOption') || '');
|
const [refreshOption, setRefreshOption] = useState(localStorage.getItem('refreshOption') || '');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
EventBus.on('refresh', (data) => {
|
const handleRefresh = (data) => {
|
||||||
if (data === 'navbar' || data === 'background' || data === 'language') {
|
if (data === 'navbar' || data === 'background' || data === 'language') {
|
||||||
setRefreshOption(localStorage.getItem('refreshOption'));
|
setRefreshOption(localStorage.getItem('refreshOption'));
|
||||||
updateRefreshText();
|
updateRefreshText();
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
EventBus.on('refresh', handleRefresh);
|
||||||
updateRefreshText();
|
updateRefreshText();
|
||||||
}, [languagecode]);
|
|
||||||
|
return () => {
|
||||||
|
EventBus.off('refresh', handleRefresh);
|
||||||
|
};
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
function updateRefreshText() {
|
function updateRefreshText() {
|
||||||
let text;
|
let text;
|
||||||
switch (localStorage.getItem('refreshOption')) {
|
switch (localStorage.getItem('refreshOption')) {
|
||||||
case 'background':
|
case 'background':
|
||||||
text = variables.getMessage('modals.main.settings.sections.background.title');
|
text = t('modals.main.settings.sections.background.title');
|
||||||
break;
|
break;
|
||||||
case 'quote':
|
case 'quote':
|
||||||
text = variables.getMessage('modals.main.settings.sections.quote.title');
|
text = t('modals.main.settings.sections.quote.title');
|
||||||
break;
|
break;
|
||||||
case 'quotebackground':
|
case 'quotebackground':
|
||||||
text = new Intl.ListFormat(
|
text = new Intl.ListFormat(
|
||||||
@@ -38,14 +43,12 @@ function Refresh() {
|
|||||||
type: 'conjunction',
|
type: 'conjunction',
|
||||||
},
|
},
|
||||||
).format([
|
).format([
|
||||||
variables.getMessage('modals.main.settings.sections.quote.title'),
|
t('modals.main.settings.sections.quote.title'),
|
||||||
variables.getMessage('modals.main.settings.sections.background.title'),
|
t('modals.main.settings.sections.background.title'),
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
text = variables.getMessage(
|
text = t('modals.main.settings.sections.appearance.navbar.refresh_options.page');
|
||||||
'modals.main.settings.sections.appearance.navbar.refresh_options.page',
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import variables from 'config/variables';
|
|||||||
import { memo, useRef, useEffect, useState, useCallback } from 'react';
|
import { memo, useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { MdSearch, MdMic } from 'react-icons/md';
|
import { MdSearch, MdMic } from 'react-icons/md';
|
||||||
import { Tooltip } from 'components/Elements';
|
import { Tooltip } from 'components/Elements';
|
||||||
|
import { useT } from 'contexts';
|
||||||
|
|
||||||
import EventBus from 'utils/eventbus';
|
import EventBus from 'utils/eventbus';
|
||||||
|
|
||||||
import './search.scss';
|
import './search.scss';
|
||||||
|
|
||||||
function Search() {
|
function Search() {
|
||||||
|
const t = useT();
|
||||||
const [microphone, setMicrophone] = useState(null);
|
const [microphone, setMicrophone] = useState(null);
|
||||||
const [classList] = useState(
|
const [classList] = useState(
|
||||||
localStorage.getItem('widgetStyle') === 'legacy' ? 'searchIcons old' : 'searchIcons',
|
localStorage.getItem('widgetStyle') === 'legacy' ? 'searchIcons old' : 'searchIcons',
|
||||||
@@ -113,14 +115,14 @@ function Search() {
|
|||||||
<div className="searchMain">
|
<div className="searchMain">
|
||||||
<div className={classList}>
|
<div className={classList}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={variables.getMessage('modals.main.settings.sections.search.voice_search')}
|
title={t('modals.main.settings.sections.search.voice_search')}
|
||||||
>
|
>
|
||||||
{microphone}
|
{microphone}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={searchButton} className="searchBar">
|
<form onSubmit={searchButton} className="searchBar">
|
||||||
<div className={classList}>
|
<div className={classList}>
|
||||||
<Tooltip title={variables.getMessage('widgets.search')}>
|
<Tooltip title={t('widgets.search')}>
|
||||||
<button className="navbarButton" onClick={searchButton} aria-label="Search">
|
<button className="navbarButton" onClick={searchButton} aria-label="Search">
|
||||||
<MdSearch />
|
<MdSearch />
|
||||||
</button>
|
</button>
|
||||||
@@ -128,7 +130,7 @@ function Search() {
|
|||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={variables.getMessage('widgets.search')}
|
placeholder={t('widgets.search')}
|
||||||
id="searchtext"
|
id="searchtext"
|
||||||
className="searchInput"
|
className="searchInput"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import variables from 'config/variables';
|
|
||||||
import { MdOutlineOpenInNew } from 'react-icons/md';
|
import { MdOutlineOpenInNew } from 'react-icons/md';
|
||||||
import languages from '@/i18n/languages.json';
|
import languages from '@/i18n/languages.json';
|
||||||
import { useMessage } from 'contexts/TranslationContext';
|
import { useT } from 'contexts/TranslationContext';
|
||||||
|
import variables from 'config/variables';
|
||||||
|
|
||||||
import { Radio } from 'components/Form/Settings';
|
import { Radio } from 'components/Form/Settings';
|
||||||
import { Header, Content } from '../Layout';
|
import { Header, Content } from '../Layout';
|
||||||
|
|
||||||
function ChooseLanguage() {
|
function ChooseLanguage() {
|
||||||
const title = useMessage('modals.welcome.sections.language.title');
|
const t = useT();
|
||||||
const description = useMessage('modals.welcome.sections.language.description');
|
const title = t('modals.welcome.sections.language.title');
|
||||||
|
const description = t('modals.welcome.sections.language.description');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Content>
|
<Content>
|
||||||
|
|||||||
Reference in New Issue
Block a user