feat: add translation percentage tracking and update related components

This commit is contained in:
alexsparkes
2026-01-24 22:33:41 +00:00
parent 17560819a1
commit 5be6955b13
9 changed files with 365 additions and 95 deletions

View File

@@ -46,7 +46,7 @@
.images-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
padding: 20px;
grid-gap: 20px;

View File

@@ -1,4 +1,12 @@
import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useMemo,
useRef,
} from 'react';
import { initTranslations, translations } from 'lib/translations';
import variables from 'config/variables';
import EventBus from 'utils/eventbus';
@@ -20,37 +28,47 @@ export function TranslationProvider({ children, initialLanguage }) {
}, [currentLanguage, initialLanguage]);
// Change language function
const changeLanguage = useCallback((newLanguage) => {
// Update the i18n instance
i18nInstance.current = initTranslations(newLanguage);
variables.language = i18nInstance.current;
variables.languagecode = newLanguage;
document.documentElement.lang = newLanguage.replace('_', '-');
const changeLanguage = useCallback(
(newLanguage) => {
// Update the i18n instance
i18nInstance.current = initTranslations(newLanguage);
variables.language = i18nInstance.current;
variables.languagecode = newLanguage;
document.documentElement.lang = newLanguage.replace('_', '-');
// Update tab name if it's still the default
const currentTabName = localStorage.getItem('tabName');
const oldDefaultTabName = i18nInstance.current?.getMessage(currentLanguage, 'tabname');
// Update tab name if it's still the default
const currentTabName = localStorage.getItem('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);
document.title = newTabName;
}
if (currentTabName === oldDefaultTabName || !currentTabName) {
const newTabName =
translations[newLanguage.replace('-', '_')]?.tabname ||
i18nInstance.current?.getMessage(newLanguage, 'tabname') ||
'Mue';
localStorage.setItem('tabName', newTabName);
document.title = newTabName;
}
// Update language in localStorage
localStorage.setItem('language', newLanguage);
// Update language in localStorage
localStorage.setItem('language', newLanguage);
// Update state to trigger re-render
setCurrentLanguage(newLanguage);
}, [currentLanguage]);
// Clear weather cache so it refreshes with the new language
localStorage.removeItem('currentWeather');
// 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]);
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(() => {
@@ -72,18 +90,17 @@ export function TranslationProvider({ children, initialLanguage }) {
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 (
<TranslationContext.Provider value={value}>
{children}
</TranslationContext.Provider>
const value = useMemo(
() => ({
language: currentLanguage,
languagecode: currentLanguage, // Alias for backward compatibility
changeLanguage,
t,
}),
[currentLanguage, changeLanguage, t],
);
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>;
}
export function useTranslation() {

View File

@@ -148,19 +148,22 @@ const CustomSettings = memo(() => {
setCurrentBackgroundIndex(newIndex);
}, [customBackground.length]);
const addCustomURL = useCallback(async (e) => {
// regex: https://ihateregex.io/expr/url/
const urlRegex =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._~#=]{1,256}\.[a-zA-Z0-9()]{1,63}\b([-a-zA-Z0-9()!@:%_.~#?&=]*)/;
if (urlRegex.test(e) === false) {
return setUrlError(variables.getMessage('widgets.quicklinks.url_error'));
}
const addCustomURL = useCallback(
async (e) => {
// regex: https://ihateregex.io/expr/url/
const urlRegex =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._~#=]{1,256}\.[a-zA-Z0-9()]{1,63}\b([-a-zA-Z0-9()!@:%_.~#?&=]*)/;
if (urlRegex.test(e) === false) {
return setUrlError(variables.getMessage('widgets.quicklinks.url_error'));
}
const newIndex = customBackground.length;
setCustomURLModal(false);
setCurrentBackgroundIndex(newIndex);
await handleCustomBackground({ target: { result: e } }, newIndex);
}, [customBackground.length, handleCustomBackground]);
const newIndex = customBackground.length;
setCustomURLModal(false);
setCurrentBackgroundIndex(newIndex);
await handleCustomBackground({ target: { result: e } }, newIndex);
},
[customBackground.length, handleCustomBackground],
);
useEffect(() => {
const dnd = customDnd.current;
@@ -229,7 +232,7 @@ const CustomSettings = memo(() => {
dnd.ondrop = null;
}
};
}, [currentBackgroundIndex, handleCustomBackground]);
}, [customBackground.length, handleCustomBackground]);
const hasVideo = customBackground.filter((bg) => bg && videoCheck(bg)).length > 0;
@@ -265,9 +268,7 @@ const CustomSettings = memo(() => {
type="settings"
onClick={uploadCustomBackground}
icon={<MdOutlineFileUpload />}
label={variables.getMessage(
'modals.main.settings.sections.background.source.upload',
)}
label={variables.getMessage('modals.main.settings.sections.background.source.upload')}
/>
<Button
type="settings"
@@ -285,10 +286,7 @@ const CustomSettings = memo(() => {
{customBackground.map((url, index) => (
<div key={index}>
{url && !videoCheck(url) ? (
<img
alt={'Custom background ' + (index || 0)}
src={customBackground[index]}
/>
<img alt={'Custom background ' + (index || 0)} src={customBackground[index]} />
) : url && videoCheck(url) ? (
<MdPersonalVideo className="customvideoicon" />
) : null}
@@ -318,12 +316,9 @@ const CustomSettings = memo(() => {
)}
</span>
<span className="subtitle">
{variables.getMessage(
'modals.main.settings.sections.background.source.formats',
{
list: 'jpeg, png, webp, webm, gif, mp4, webm, ogg',
},
)}
{variables.getMessage('modals.main.settings.sections.background.source.formats', {
list: 'jpeg, png, webp, webm, gif, mp4, webm, ogg',
})}
</span>
<Button
type="settings"

View File

@@ -7,6 +7,7 @@ import { TextField, InputAdornment } from '@mui/material';
import { Radio, Checkbox } from 'components/Form/Settings';
import languages from '@/i18n/languages.json';
import translationPercentages from '@/i18n/translationPercentages.json';
const LanguageOptions = () => {
const t = useT();
@@ -29,6 +30,7 @@ const LanguageOptions = () => {
// Convert language code to ISO format for Intl.DisplayNames
// e.g., "en_GB" -> "en-GB", "zh_CN" -> "zh-CN"
const isoCode = lang.value.replace('_', '-');
const percentage = translationPercentages[lang.value]?.percent || 0;
let translatedName;
try {
@@ -37,25 +39,31 @@ const LanguageOptions = () => {
if (translatedName) {
translatedName = translatedName.split(' (')[0];
}
} catch (e) {
} catch {
// Fallback if the code isn't recognized
translatedName = nativeName;
}
// Show native name first, then translated name in brackets (greyed and smaller)
const displayName = !translatedName || translatedName === nativeName
? nativeName
: (
<>
{nativeName}{' '}
<span style={{ color: '#999', fontSize: '0.85em' }}>({translatedName})</span>
</>
);
const displayName =
!translatedName || translatedName === nativeName ? (
<>
{nativeName} <span style={{ color: '#999', fontSize: '0.85em' }}>({percentage}%)</span>
</>
) : (
<>
{nativeName}{' '}
<span style={{ color: '#999', fontSize: '0.85em' }}>
({translatedName} {percentage}%)
</span>
</>
);
return {
name: displayName,
value: lang.value,
nativeName,
percentage,
searchText: `${nativeName} ${translatedName || ''}`.toLowerCase(),
};
});
@@ -68,26 +76,26 @@ const LanguageOptions = () => {
const filteredLanguages = useMemo(() => {
if (!searchQuery.trim()) return languageOptions;
const query = searchQuery.toLowerCase();
return languageOptions.filter(lang => lang.searchText.includes(query));
return languageOptions.filter((lang) => lang.searchText.includes(query));
}, [languageOptions, searchQuery]);
// Detect system language
const systemLanguage = useMemo(() => {
const browserLang = navigator.language.replace('-', '_');
// Check exact match first, then base language
return languages.find(l => l.value === browserLang) ||
languages.find(l => l.value.startsWith(browserLang.split('_')[0]));
return (
languages.find((l) => l.value === browserLang) ||
languages.find((l) => l.value.startsWith(browserLang.split('_')[0]))
);
}, []);
// Find current language option for display
const currentLangOption = languageOptions.find(l => l.value === currentLanguage);
const currentLangOption = languageOptions.find((l) => l.value === currentLanguage);
return (
<>
<div className="modalHeader">
<span className="mainTitle">
{languageTitle}
</span>
<span className="mainTitle">{languageTitle}</span>
<div className="headerActions">
<a
className="link"
@@ -107,7 +115,14 @@ const LanguageOptions = () => {
category="other"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<TextField
placeholder={t('modals.main.settings.sections.language.search')}
value={searchQuery}
@@ -140,7 +155,8 @@ const LanguageOptions = () => {
/>
{currentLangOption && (
<div style={{ color: '#888', whiteSpace: 'nowrap' }}>
{t('modals.main.settings.sections.language.current')}: <strong style={{ color: 'var(--fg)' }}>{currentLangOption.nativeName}</strong>
{t('modals.main.settings.sections.language.current')}:{' '}
<strong style={{ color: 'var(--fg)' }}>{currentLangOption.nativeName}</strong>
</div>
)}
</div>

View File

@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
import { MdOutlineOpenInNew, MdSearch } from 'react-icons/md';
import { TextField, InputAdornment } from '@mui/material';
import languages from '@/i18n/languages.json';
import translationPercentages from '@/i18n/translationPercentages.json';
import { useT, useTranslation } from 'contexts/TranslationContext';
import variables from 'config/variables';
@@ -23,6 +24,7 @@ function ChooseLanguage() {
const mappedLanguages = languages.map((lang) => {
const nativeName = lang.name;
const isoCode = lang.value.replace('_', '-');
const percentage = translationPercentages[lang.value]?.percent || 0;
let translatedName;
try {
@@ -30,17 +32,21 @@ function ChooseLanguage() {
if (translatedName) {
translatedName = translatedName.split(' (')[0];
}
} catch (e) {
} catch {
translatedName = nativeName;
}
const displayName =
!translatedName || translatedName === nativeName ? (
nativeName
<>
{nativeName} <span style={{ color: '#999', fontSize: '0.85em' }}>({percentage}%)</span>
</>
) : (
<>
{nativeName}{' '}
<span style={{ color: '#999', fontSize: '0.85em' }}>({translatedName})</span>
<span style={{ color: '#999', fontSize: '0.85em' }}>
({translatedName} {percentage}%)
</span>
</>
);
@@ -48,6 +54,7 @@ function ChooseLanguage() {
name: displayName,
value: lang.value,
nativeName,
percentage,
searchText: `${nativeName} ${translatedName || ''}`.toLowerCase(),
};
});
@@ -60,14 +67,16 @@ function ChooseLanguage() {
const filteredLanguages = useMemo(() => {
if (!searchQuery.trim()) return languageOptions;
const query = searchQuery.toLowerCase();
return languageOptions.filter(lang => lang.searchText.includes(query));
return languageOptions.filter((lang) => lang.searchText.includes(query));
}, [languageOptions, searchQuery]);
// Detect system language
const systemLanguage = useMemo(() => {
const browserLang = navigator.language.replace('-', '_');
return languages.find(l => l.value === browserLang) ||
languages.find(l => l.value.startsWith(browserLang.split('_')[0]));
return (
languages.find((l) => l.value === browserLang) ||
languages.find((l) => l.value.startsWith(browserLang.split('_')[0]))
);
}, []);
return (

View File

@@ -0,0 +1,138 @@
{
"en_GB": {
"percent": 100,
"lastChange": "2025-11-01T18:26:28.748698Z"
},
"de_DE": {
"percent": 99.6,
"lastChange": "2025-11-02T18:51:15.668198Z"
},
"en_US": {
"percent": 99.4,
"lastChange": "2025-11-27T16:00:13.299953Z"
},
"es": {
"percent": 99.8,
"lastChange": "2025-12-13T18:00:20.128157Z"
},
"fr": {
"percent": 100,
"lastChange": "2026-01-02T11:01:50.048816Z"
},
"id_ID": {
"percent": 99,
"lastChange": "2025-11-02T18:51:31.688786Z"
},
"nl": {
"percent": 99.4,
"lastChange": "2025-11-02T18:51:35.270456Z"
},
"no": {
"percent": 99.4,
"lastChange": "2025-11-02T18:51:39.134759Z"
},
"ru": {
"percent": 99.6,
"lastChange": "2025-11-02T18:51:43.544130Z"
},
"tr_TR": {
"percent": 99.6,
"lastChange": "2025-11-02T18:51:47.582567Z"
},
"zh_CN": {
"percent": 99.2,
"lastChange": "2025-11-02T18:51:51.182076Z"
},
"pt_BR": {
"percent": 98.8,
"lastChange": "2025-11-02T18:51:54.789336Z"
},
"es_419": {
"percent": 99.6,
"lastChange": "2025-11-02T18:51:58.826381Z"
},
"pt_PT": {
"percent": 99,
"lastChange": "2025-11-02T18:52:02.248380Z"
},
"bn": {
"percent": 99.6,
"lastChange": "2025-11-02T18:52:06.416365Z"
},
"sl": {
"percent": 99.6,
"lastChange": "2025-11-02T18:52:09.801136Z"
},
"fa": {
"percent": 99.4,
"lastChange": "2026-01-03T18:06:23.197313Z"
},
"az": {
"percent": 99.6,
"lastChange": "2025-11-02T18:52:16.631811Z"
},
"azb": {
"percent": 99.6,
"lastChange": "2025-11-02T18:52:20.051219Z"
},
"zh_Hant": {
"percent": 99.6,
"lastChange": "2025-11-02T18:52:23.571948Z"
},
"lt": {
"percent": 99.6,
"lastChange": "2025-11-02T18:52:26.957231Z"
},
"lv": {
"percent": 99.6,
"lastChange": "2025-11-02T18:52:30.768664Z"
},
"et": {
"percent": 99.6,
"lastChange": "2025-11-02T18:52:34.391330Z"
},
"vi": {
"percent": 0.1,
"lastChange": "2025-11-01T18:26:36.276619Z"
},
"ja": {
"percent": 1.3,
"lastChange": "2025-11-01T18:26:33.191724Z"
},
"ta": {
"percent": 99.6,
"lastChange": "2025-11-02T18:52:38.046456Z"
},
"ar": {
"percent": 99.6,
"lastChange": "2025-11-02T18:52:41.378895Z"
},
"sv": {
"percent": 1.3,
"lastChange": "2025-11-01T18:26:35.299601Z"
},
"el": {
"percent": 0.1,
"lastChange": "2025-11-01T18:26:30.693625Z"
},
"hu": {
"percent": 0,
"lastChange": "2025-11-01T18:26:32.407284Z"
},
"arz": {
"percent": 0.1,
"lastChange": "2025-11-01T18:26:29.272472Z"
},
"uk": {
"percent": 100,
"lastChange": "2025-11-02T18:52:42.303925Z"
},
"peo": {
"percent": 3.7,
"lastChange": "2025-12-29T10:00:22.520132Z"
},
"ko": {
"percent": 0,
"lastChange": "2026-01-14T23:43:45.065715Z"
}
}