Merge pull request #27 from SebFox2011/PR_TranslateProposal

Add React i18n for translate proposal. First, settings dialog translate
pull/65/head
accius 3 days ago committed by GitHub
commit d811ec766c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -18,8 +18,11 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"i18next": "^25.8.0",
"i18next-browser-languagedetector": "^8.2.0",
"mqtt": "^5.3.4", "mqtt": "^5.3.4",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"react-i18next": "^16.5.4",
"satellite.js": "^5.0.0", "satellite.js": "^5.0.0",
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
@ -41,4 +44,4 @@
], ],
"author": "K0CJH", "author": "K0CJH",
"license": "MIT" "license": "MIT"
} }

@ -4,6 +4,7 @@
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { calculateGridSquare } from '../utils/geo.js'; import { calculateGridSquare } from '../utils/geo.js';
import { useTranslation, Trans } from 'react-i18next';
export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [callsign, setCallsign] = useState(config?.callsign || ''); const [callsign, setCallsign] = useState(config?.callsign || '');
@ -13,6 +14,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [theme, setTheme] = useState(config?.theme || 'dark'); const [theme, setTheme] = useState(config?.theme || 'dark');
const [layout, setLayout] = useState(config?.layout || 'modern'); const [layout, setLayout] = useState(config?.layout || 'modern');
const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy'); const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy');
const { t } = useTranslation();
// Layer controls // Layer controls
const [layers, setLayers] = useState([]); const [layers, setLayers] = useState([]);
@ -99,11 +101,11 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
}, },
(error) => { (error) => {
console.error('Geolocation error:', error); console.error('Geolocation error:', error);
alert('Unable to get location. Please enter manually.'); alert(t('station.settings.useLocation.error1'));
} }
); );
} else { } else {
alert('Geolocation not supported by your browser.'); alert(t('station.settings.useLocation.error2'));
} }
}; };
@ -152,16 +154,22 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
if (!isOpen) return null; if (!isOpen) return null;
const Code = ({ children }) => (
<code style={{ background: 'var(--bg-tertiary)', padding: '2px 4px', borderRadius: '3px' }}>
{children}
</code>
);
const themeDescriptions = { const themeDescriptions = {
dark: '→ Modern dark theme (default)', dark: t('station.settings.theme.dark.describe'),
light: '→ Light theme for daytime use', light: t('station.settings.theme.light.describe'),
legacy: '→ Green terminal CRT style', legacy: t('station.settings.theme.legacy.describe'),
retro: '→ 90s Windows retro style' retro: t('station.settings.theme.retro.describe')
}; };
const layoutDescriptions = { const layoutDescriptions = {
modern: '→ Modern responsive grid layout', modern: t('station.settings.layout.modern.describe'),
classic: '→ Original HamClock-style layout' classic: t('station.settings.layout.classic.describe')
}; };
return ( return (
@ -194,7 +202,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
fontFamily: 'Orbitron, monospace', fontFamily: 'Orbitron, monospace',
fontSize: '20px' fontSize: '20px'
}}> }}>
Settings {t('station.settings.title')}
</h2> </h2>
{/* Tab Navigation */} {/* Tab Navigation */}
@ -244,10 +252,32 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
{/* Station Settings Tab */} {/* Station Settings Tab */}
{activeTab === 'station' && ( {activeTab === 'station' && (
<> <>
{/* First-time setup banner */}
{(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && (
<div style={{
background: 'rgba(255, 193, 7, 0.15)',
border: '1px solid var(--accent-amber)',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '20px',
fontSize: '13px'
}}>
<div style={{ color: 'var(--accent-amber)', fontWeight: '700', marginBottom: '6px' }}>
{t("station.settings.welcome")}
</div>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{t("station.settings.describe")}
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '11px', marginTop: '8px' }}>
<Trans i18nKey="station.settings.tip.env" components={{ envExample: <Code />, env: <Code /> }} />
</div>
</div>
)}
{/* Callsign */} {/* Callsign */}
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}> <label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
Your Callsign {t('station.settings.callsign')}
</label> </label>
<input <input
type="text" type="text"
@ -271,7 +301,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
{/* Grid Square */} {/* Grid Square */}
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}> <label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
Grid Square (or enter Lat/Lon below) {t('station.settings.locator')}
</label> </label>
<input <input
type="text" type="text"
@ -298,15 +328,13 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}>
<div> <div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}> <label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}>
Latitude {t('station.settings.latitude')}
</label> </label>
<input <input
type="number" type="number"
step="0.000001" step="0.000001"
// value={lat} value={isNaN(lat) ? '' : lat}
// onChange={(e) => setLat(parseFloat(e.target.value))} onChange={(e) => setLat(parseFloat(e.target.value) || 0)}
value={isNaN(lat) ? '' : lat}
onChange={(e) => setLat(parseFloat(e.target.value) || 0)}
style={{ style={{
width: '100%', width: '100%',
padding: '10px', padding: '10px',
@ -322,15 +350,13 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
</div> </div>
<div> <div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}> <label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}>
Longitude {t('station.settings.longitude')}
</label> </label>
<input <input
type="number" type="number"
step="0.000001" step="0.000001"
// value={lon} value={isNaN(lon) ? '' : lon}
// onChange={(e) => setLon(parseFloat(e.target.value))} onChange={(e) => setLon(parseFloat(e.target.value) || 0)}
value={isNaN(lon) ? '' : lon}
onChange={(e) => setLon(parseFloat(e.target.value) || 0)}
style={{ style={{
width: '100%', width: '100%',
padding: '10px', padding: '10px',
@ -360,31 +386,31 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
marginBottom: '20px' marginBottom: '20px'
}} }}
> >
📍 Use My Current Location {t('station.settings.useLocation')}
</button> </button>
{/* Theme */} {/* Theme */}
<div style={{ marginBottom: '8px' }}> <div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}> <label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
Theme {t('station.settings.theme')}
</label> </label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px' }}>
{['dark', 'light', 'legacy', 'retro'].map((t) => ( {['dark', 'light', 'legacy', 'retro'].map((th) => (
<button <button
key={t} key={th}
onClick={() => setTheme(t)} onClick={() => setTheme(th)}
style={{ style={{
padding: '10px', padding: '10px',
background: theme === t ? 'var(--accent-amber)' : 'var(--bg-tertiary)', background: theme === th ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
border: `1px solid ${theme === t ? 'var(--accent-amber)' : 'var(--border-color)'}`, border: `1px solid ${theme === th ? 'var(--accent-amber)' : 'var(--border-color)'}`,
borderRadius: '6px', borderRadius: '6px',
color: theme === t ? '#000' : 'var(--text-secondary)', color: theme === th ? '#000' : 'var(--text-secondary)',
fontSize: '12px', fontSize: '12px',
cursor: 'pointer', cursor: 'pointer',
fontWeight: theme === t ? '600' : '400' fontWeight: theme === th ? '600' : '400'
}} }}
> >
{t === 'dark' ? '🌙' : t === 'light' ? '☀️' : t === 'legacy' ? '💻' : '🪟'} {t.charAt(0).toUpperCase() + t.slice(1)} {th === 'dark' ? '🌙' : th === 'light' ? '☀️' : th === 'legacy' ? '💻' : '🪟'} {t('station.settings.theme.' + th)}
</button> </button>
))} ))}
</div> </div>
@ -396,7 +422,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
{/* Layout */} {/* Layout */}
<div style={{ marginBottom: '8px' }}> <div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}> <label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
Layout {t('station.settings.layout')}
</label> </label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
{['modern', 'classic'].map((l) => ( {['modern', 'classic'].map((l) => (
@ -414,7 +440,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
fontWeight: layout === l ? '600' : '400' fontWeight: layout === l ? '600' : '400'
}} }}
> >
{l === 'modern' ? '🖥️' : '📺'} {l.charAt(0).toUpperCase() + l.slice(1)} {l === 'modern' ? '🖥️' : '📺'} {t('station.settings.layout.' + l)}
</button> </button>
))} ))}
</div> </div>
@ -426,7 +452,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
{/* DX Cluster Source */} {/* DX Cluster Source */}
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}> <label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
DX Cluster Source {t('station.settings.dx.title')}
</label> </label>
<select <select
value={dxClusterSource} value={dxClusterSource}
@ -443,13 +469,13 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
cursor: 'pointer' cursor: 'pointer'
}} }}
> >
<option value="dxspider-proxy"> DX Spider Proxy (Recommended)</option> <option value="dxspider-proxy">{t('station.settings.dx.option1')}</option>
<option value="hamqth">HamQTH Cluster</option> <option value="hamqth">{t('station.settings.dx.option2')}</option>
<option value="dxwatch">DXWatch</option> <option value="dxwatch">{t('station.settings.dx.option3')}</option>
<option value="auto">Auto (try all sources)</option> <option value="auto">{t('station.settings.dx.option4')}</option>
</select> </select>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}> <div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
Real-time DX Spider feed via our dedicated proxy service {t('station.settings.dx.describe')}
</div> </div>
</div> </div>
</> </>
@ -572,7 +598,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
cursor: 'pointer' cursor: 'pointer'
}} }}
> >
Cancel {t('cancel')}
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
@ -587,12 +613,12 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
cursor: 'pointer' cursor: 'pointer'
}} }}
> >
Save Settings {t('station.settings.button.save')}
</button> </button>
</div> </div>
<div style={{ textAlign: 'center', marginTop: '16px', fontSize: '11px', color: 'var(--text-muted)' }}> <div style={{ textAlign: 'center', marginTop: '16px', fontSize: '11px', color: 'var(--text-muted)' }}>
Settings are saved in your browser {t('station.settings.button.save.confirm')}
</div> </div>
</div> </div>
</div> </div>

@ -0,0 +1,40 @@
{
"cancel": "Cancel",
"station.settings.altitude": "Altitude (m)",
"station.settings.antenna": "Antenna",
"station.settings.button.save": "Save Settings",
"station.settings.button.save.confirm": "Settings are saved in your browser",
"station.settings.callsign": "Your Callsign",
"station.settings.describe": "Please enter your callsign and grid square to get started. Your settings will be saved in your browser.",
"station.settings.dx.describe": "→ Real-time DX Spider feed via our dedicated proxy service",
"station.settings.dx.option1": "⭐ DX Spider Proxy (Recommended)",
"station.settings.dx.option2": "HamQTH Cluster",
"station.settings.dx.option3": "DXWatch",
"station.settings.dx.option4": "Auto (try all sources)",
"station.settings.dx.title": "DX Cluster Source",
"station.settings.layout": "Layout",
"station.settings.layout.classic": "Classic",
"station.settings.layout.classic.describe": "→ Original HamClock-style layout",
"station.settings.layout.modern": "Modern",
"station.settings.layout.modern.describe": "→ Modern responsive grid layout",
"station.settings.latitude": "Latitude",
"station.settings.locator": "Grid Square (or enter Lat/Lon below)",
"station.settings.longitude": "Longitude",
"station.settings.power": "Power (W)",
"station.settings.theme": "THEME",
"station.settings.theme.dark": "Dark",
"station.settings.theme.dark.describe": "→ Modern dark theme (default)",
"station.settings.theme.legacy": "Legacy",
"station.settings.theme.legacy.describe": "→ Green CRT terminal style",
"station.settings.theme.light": "Light",
"station.settings.theme.light.describe": "→ Light theme for daytime use",
"station.settings.theme.retro": "Retro",
"station.settings.theme.retro.describe": "→ 90s Windows retro style",
"station.settings.timezone": "Timezone",
"station.settings.title": "Station Settings",
"station.settings.tip.env": "💡 Tip: For permanent config, copy <envExample>.env.example</envExample> to <env>.env</env> and set CALLSIGN and LOCATOR",
"station.settings.useLocation": "📍 Use My Current Location",
"station.settings.useLocation.error1": "Unable to get location. Please enter manually.",
"station.settings.useLocation.error2": "Geolocation is not supported by your browser.",
"station.settings.welcome": "👋 Welcome to OpenHamClock!"
}

@ -0,0 +1,40 @@
{
"Cancel": "Annuler",
"station.settings.altitude": "Altitude (m)",
"station.settings.antenna": "Antenne",
"station.settings.button.save": "Enregistrer les paramètres",
"station.settings.button.save.confirm": "Les paramètres sont enregistrés dans votre navigateur",
"station.settings.callsign": "Indicatif d'appel",
"station.settings.describe": "Veuillez entrer votre indicatif d'appel et votre carré de grille pour commencer. Vos paramètres seront enregistrés dans votre navigateur.",
"station.settings.dx.describe": "→ Flux en temps réel de DX Spider via notre service proxy dédié",
"station.settings.dx.option1": "⭐ Proxy DX Spider (Recommandé)",
"station.settings.dx.option2": "Cluster HamQTH",
"station.settings.dx.option3": "DXWatch",
"station.settings.dx.option4": "Auto (essayer toutes les sources)",
"station.settings.dx.title": "Source du cluster DX",
"station.settings.layout": "Disposition",
"station.settings.layout.classic": "Classique",
"station.settings.layout.classic.describe": "→ Disposition de style HamClock original",
"station.settings.layout.modern": "Moderne",
"station.settings.layout.modern.describe": "→ Disposition en grille réactive moderne",
"station.settings.latitude": "Latitude",
"station.settings.locator": "Carré de grille (ou entrez Lat/Lon ci-dessous)",
"station.settings.longitude": "Longitude",
"station.settings.power": "Puissance (W)",
"station.settings.theme": "THÈME",
"station.settings.theme.dark": "Sombre",
"station.settings.theme.dark.describe": "→ Thème sombre moderne (par défaut)",
"station.settings.theme.legacy": "Classique",
"station.settings.theme.legacy.describe": "→ Style CRT terminal vert",
"station.settings.theme.light": "Clair",
"station.settings.theme.light.describe": "→ Thème clair pour une utilisation diurne",
"station.settings.theme.retro": "Rétro",
"station.settings.theme.retro.describe": "→ Style rétro Windows des années 90",
"station.settings.timezone": "Fuseau horaire",
"station.settings.title": "⚙ Paramètres de la station",
"station.settings.tip.env": "💡 Astuce : Pour une configuration permanente, copiez <envExample>.env.example</envExample> vers <env>.env</env> et définissez Indicatif d'appel et Carré de grille",
"station.settings.useLocation": "📍 Utiliser ma position actuelle",
"station.settings.useLocation.error1": "Impossible d'obtenir la position. Veuillez entrer manuellement.",
"station.settings.useLocation.error2": "La géolocalisation n'est pas prise en charge par votre navigateur.",
"station.settings.welcome": "👋 Bienvenue sur OpenHamClock !"
}

@ -0,0 +1,31 @@
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import translationFR from './fr.json';
import translationEN from './en.json';
export const resources = {
fr: { translation: translationFR },
en: { translation: translationEN }
} ;
i18n
.use(LanguageDetector) // Automatically detects the user's language
.use(initReactI18next)
.init({
fallbackLng: 'en',
resources: {
fr: {
translation: translationFR
},
en: {
translation: translationEN
}
},
interpolation: {
escapeValue: false
}
});
export default i18n;

@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import './styles/main.css'; import './styles/main.css';
import './lang/i18n';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>

Loading…
Cancel
Save

Powered by TurnKey Linux.