From 3b925f50588d1418ff24a53b1ff72a897749f661 Mon Sep 17 00:00:00 2001 From: SebFox2011 Date: Mon, 2 Feb 2026 20:18:28 +0100 Subject: [PATCH 1/2] Add React i18n for translate proposal. First, settings dialog translate --- package.json | 3 ++ src/components/SettingsPanel.jsx | 85 +++++++++++++++++--------------- src/lang/en.json | 40 +++++++++++++++ src/lang/fr.json | 40 +++++++++++++++ src/lang/i18n.js | 31 ++++++++++++ src/main.jsx | 1 + 6 files changed, 161 insertions(+), 39 deletions(-) create mode 100644 src/lang/en.json create mode 100644 src/lang/fr.json create mode 100644 src/lang/i18n.js diff --git a/package.json b/package.json index ed16391..575fdc2 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,10 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "i18next": "^25.8.0", + "i18next-browser-languagedetector": "^8.2.0", "node-fetch": "^2.7.0", + "react-i18next": "^16.5.4", "satellite.js": "^5.0.0", "ws": "^8.14.2" }, diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx index 3eb5f52..656c45b 100644 --- a/src/components/SettingsPanel.jsx +++ b/src/components/SettingsPanel.jsx @@ -4,6 +4,7 @@ */ import React, { useState, useEffect } from 'react'; import { calculateGridSquare } from '../utils/geo.js'; +import { useTranslation, Trans } from 'react-i18next'; export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { 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 [layout, setLayout] = useState(config?.layout || 'modern'); const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy'); + const { t } = useTranslation(); useEffect(() => { if (config) { @@ -83,11 +85,11 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { }, (error) => { console.error('Geolocation error:', error); - alert('Unable to get location. Please enter manually.'); + alert(t('station.settings.useLocation.error1')); } ); } else { - alert('Geolocation not supported by your browser.'); + alert(t('station.settings.useLocation.error2')); } }; @@ -106,16 +108,22 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { if (!isOpen) return null; + const Code = ({ children }) => ( + + {children} + + ); + const themeDescriptions = { - dark: '→ Modern dark theme (default)', - light: '→ Light theme for daytime use', - legacy: '→ Green terminal CRT style', - retro: '→ 90s Windows retro style' + dark: t('station.settings.theme.dark.describe'), + light: t('station.settings.theme.light.describe'), + legacy: t('station.settings.theme.legacy.describe'), + retro: t('station.settings.theme.retro.describe') }; const layoutDescriptions = { - modern: '→ Modern responsive grid layout', - classic: '→ Original HamClock-style layout' + modern: t('station.settings.layout.modern.describe'), + classic: t('station.settings.layout.classic.describe') }; return ( @@ -136,7 +144,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { border: '2px solid var(--accent-amber)', borderRadius: '12px', padding: '24px', - width: '420px', + width: '480px', maxHeight: '90vh', overflowY: 'auto' }}> @@ -148,7 +156,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { fontFamily: 'Orbitron, monospace', fontSize: '20px' }}> - ⚙ Station Settings + {t('station.settings.title')} {/* First-time setup banner */} @@ -162,14 +170,13 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { fontSize: '13px' }}>
- 👋 Welcome to OpenHamClock! + {t("station.settings.welcome")}
- Please enter your callsign and grid square to get started. - Your settings will be saved in your browser. + {t("station.settings.describe")}
- 💡 Tip: For permanent config, copy .env.example to .env and set CALLSIGN and LOCATOR + , env: }} />
)} @@ -177,7 +184,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { {/* Callsign */}
{ {/* Grid Square */}
{
{
{ marginBottom: '20px' }} > - 📍 Use My Current Location + {t('station.settings.useLocation')} {/* Theme */}
- {['dark', 'light', 'legacy', 'retro'].map((t) => ( + {['dark', 'light', 'legacy', 'retro'].map((theme) => ( ))}
@@ -323,7 +330,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { {/* Layout */}
{['modern', 'classic'].map((l) => ( @@ -341,7 +348,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { fontWeight: layout === l ? '600' : '400' }} > - {l === 'modern' ? '🖥️' : '📺'} {l.charAt(0).toUpperCase() + l.slice(1)} + {l === 'modern' ? '🖥️' : '📺'} {t('station.settings.layout.' + l)} ))}
@@ -353,7 +360,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { {/* DX Cluster Source */}
- → Real-time DX Spider feed via our dedicated proxy service + {t('station.settings.dx.describe')}
@@ -394,7 +401,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { cursor: 'pointer' }} > - Cancel + {t('cancel')}
- Settings are saved in your browser + {t('station.settings.button.save.confirm')}
diff --git a/src/lang/en.json b/src/lang/en.json new file mode 100644 index 0000000..41da978 --- /dev/null +++ b/src/lang/en.json @@ -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 .env.example to .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!" +} diff --git a/src/lang/fr.json b/src/lang/fr.json new file mode 100644 index 0000000..b1c0e93 --- /dev/null +++ b/src/lang/fr.json @@ -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 .env.example vers .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 !" +} diff --git a/src/lang/i18n.js b/src/lang/i18n.js new file mode 100644 index 0000000..0596300 --- /dev/null +++ b/src/lang/i18n.js @@ -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; \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index 4302c8f..e582a26 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import './styles/main.css'; +import './lang/i18n'; ReactDOM.createRoot(document.getElementById('root')).render( From e585ad3b943732c0d85ec1e2dd464b33bf022484 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 2 Feb 2026 16:08:14 -0500 Subject: [PATCH 2/2] Add extensible plugin system for map layers - Implement plugin registry architecture for zero-core-modification layer additions - Add PluginLayer component for React-based plugin integration - Create two example plugins: Weather Radar (WMS) and Earthquakes (USGS API) - Add Settings panel controls for layer enable/disable and opacity - Implement localStorage persistence for user preferences - Include comprehensive developer documentation and API reference This allows developers to add custom map layers without modifying core code. Plugins are self-contained modules with metadata, lifecycle hooks, and cleanup. --- src/components/PluginLayer.jsx | 16 + src/components/SettingsPanel.jsx | 616 +++++++---- src/components/WorldMap.jsx | 105 ++ src/plugins/OpenHamClock-Plugin-Guide.md | 1204 ++++++++++++++++++++++ src/plugins/layerRegistry.js | 32 + src/plugins/layers/useEarthquakes.js | 145 +++ src/plugins/layers/useWXRadar.js | 88 ++ 7 files changed, 1987 insertions(+), 219 deletions(-) create mode 100644 src/components/PluginLayer.jsx create mode 100644 src/plugins/OpenHamClock-Plugin-Guide.md create mode 100644 src/plugins/layerRegistry.js create mode 100644 src/plugins/layers/useEarthquakes.js create mode 100644 src/plugins/layers/useWXRadar.js diff --git a/src/components/PluginLayer.jsx b/src/components/PluginLayer.jsx new file mode 100644 index 0000000..ced3f66 --- /dev/null +++ b/src/components/PluginLayer.jsx @@ -0,0 +1,16 @@ +/** + * PluginLayer Component + * Renders a single plugin layer using its hook + */ +import React from 'react'; + +export const PluginLayer = ({ plugin, enabled, opacity, map }) => { + // Call the plugin's hook (this is allowed because it's in a component) + const result = plugin.hook({ enabled, opacity, map }); + + // Plugin hook handles its own rendering to the map + // This component doesn't render anything to the DOM + return null; +}; + +export default PluginLayer; diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx index 3eb5f52..607815f 100644 --- a/src/components/SettingsPanel.jsx +++ b/src/components/SettingsPanel.jsx @@ -1,6 +1,6 @@ /** * SettingsPanel Component - * Full settings modal matching production version + * Full settings modal with map layer controls */ import React, { useState, useEffect } from 'react'; import { calculateGridSquare } from '../utils/geo.js'; @@ -13,6 +13,10 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { const [theme, setTheme] = useState(config?.theme || 'dark'); const [layout, setLayout] = useState(config?.layout || 'modern'); const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy'); + + // Layer controls + const [layers, setLayers] = useState([]); + const [activeTab, setActiveTab] = useState('station'); useEffect(() => { if (config) { @@ -22,19 +26,33 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { setTheme(config.theme || 'dark'); setLayout(config.layout || 'modern'); setDxClusterSource(config.dxClusterSource || 'dxspider-proxy'); - // Use locator from config, or calculate from coordinates - if (config.locator) { - setGridSquare(config.locator); - } else if (config.location?.lat && config.location?.lon) { + if (config.location?.lat && config.location?.lon) { setGridSquare(calculateGridSquare(config.location.lat, config.location.lon)); } } }, [config, isOpen]); - // Update lat/lon when grid square changes + // Load layers when panel opens + useEffect(() => { + if (isOpen && window.hamclockLayerControls) { + setLayers(window.hamclockLayerControls.layers || []); + } + }, [isOpen]); + + // Refresh layers periodically + useEffect(() => { + if (isOpen && activeTab === 'layers') { + const interval = setInterval(() => { + if (window.hamclockLayerControls) { + setLayers([...window.hamclockLayerControls.layers]); + } + }, 200); + return () => clearInterval(interval); + } + }, [isOpen, activeTab]); + const handleGridChange = (grid) => { setGridSquare(grid.toUpperCase()); - // Parse grid square to lat/lon if valid (6 char) if (grid.length >= 4) { const parsed = parseGridSquare(grid); if (parsed) { @@ -44,7 +62,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { } }; - // Parse grid square to coordinates const parseGridSquare = (grid) => { grid = grid.toUpperCase(); if (grid.length < 4) return null; @@ -67,7 +84,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { return { lat, lon }; }; - // Update grid when lat/lon changes useEffect(() => { if (lat && lon) { setGridSquare(calculateGridSquare(lat, lon)); @@ -91,11 +107,41 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { } }; + const handleToggleLayer = (layerId) => { + if (window.hamclockLayerControls) { + const layer = layers.find(l => l.id === layerId); + const newEnabledState = !layer.enabled; + + // Update the control + window.hamclockLayerControls.toggleLayer(layerId, newEnabledState); + + // Force immediate UI update + setLayers(prevLayers => + prevLayers.map(l => + l.id === layerId ? { ...l, enabled: newEnabledState } : l + ) + ); + + // Refresh after a short delay to get the updated state + setTimeout(() => { + if (window.hamclockLayerControls) { + setLayers([...window.hamclockLayerControls.layers]); + } + }, 100); + } + }; + + const handleOpacityChange = (layerId, opacity) => { + if (window.hamclockLayerControls) { + window.hamclockLayerControls.setOpacity(layerId, opacity); + setLayers([...window.hamclockLayerControls.layers]); + } + }; + const handleSave = () => { onSave({ ...config, callsign: callsign.toUpperCase(), - locator: gridSquare.toUpperCase(), location: { lat: parseFloat(lat), lon: parseFloat(lon) }, theme, layout, @@ -136,249 +182,381 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { border: '2px solid var(--accent-amber)', borderRadius: '12px', padding: '24px', - width: '420px', + width: '520px', maxHeight: '90vh', overflowY: 'auto' }}>

- ⚙ Station Settings + ⚙ Settings

- {/* First-time setup banner */} - {(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && ( -
-
- 👋 Welcome to OpenHamClock! -
-
- Please enter your callsign and grid square to get started. - Your settings will be saved in your browser. -
-
- 💡 Tip: For permanent config, copy .env.example to .env and set CALLSIGN and LOCATOR -
-
- )} - - {/* Callsign */} -
- - setCallsign(e.target.value.toUpperCase())} + {/* Tab Navigation */} +
+
- - {/* Grid Square */} -
- - handleGridChange(e.target.value)} - placeholder="FN20nc" - maxLength={6} + > + 📡 Station + +
- {/* Lat/Lon */} -
-
- - setLat(parseFloat(e.target.value))} - style={{ - width: '100%', - padding: '10px', - background: 'var(--bg-tertiary)', - border: '1px solid var(--border-color)', - borderRadius: '6px', - color: 'var(--text-primary)', - fontSize: '14px', - fontFamily: 'JetBrains Mono, monospace', - boxSizing: 'border-box' - }} - /> -
-
- - setLon(parseFloat(e.target.value))} + {/* Station Settings Tab */} + {activeTab === 'station' && ( + <> + {/* Callsign */} +
+ + setCallsign(e.target.value.toUpperCase())} + style={{ + width: '100%', + padding: '12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + color: 'var(--accent-amber)', + fontSize: '18px', + fontFamily: 'JetBrains Mono, monospace', + fontWeight: '700', + boxSizing: 'border-box' + }} + /> +
+ + {/* Grid Square */} +
+ + handleGridChange(e.target.value)} + placeholder="FN20nc" + maxLength={6} + style={{ + width: '100%', + padding: '12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + color: 'var(--accent-amber)', + fontSize: '18px', + fontFamily: 'JetBrains Mono, monospace', + fontWeight: '700', + boxSizing: 'border-box' + }} + /> +
+ + {/* Lat/Lon */} +
+
+ + setLat(parseFloat(e.target.value))} + value={isNaN(lat) ? '' : lat} + onChange={(e) => setLat(parseFloat(e.target.value) || 0)} + style={{ + width: '100%', + padding: '10px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + color: 'var(--text-primary)', + fontSize: '14px', + fontFamily: 'JetBrains Mono, monospace', + boxSizing: 'border-box' + }} + /> +
+
+ + setLon(parseFloat(e.target.value))} + value={isNaN(lon) ? '' : lon} + onChange={(e) => setLon(parseFloat(e.target.value) || 0)} + style={{ + width: '100%', + padding: '10px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + color: 'var(--text-primary)', + fontSize: '14px', + fontFamily: 'JetBrains Mono, monospace', + boxSizing: 'border-box' + }} + /> +
+
+ +
-
+ > + 📍 Use My Current Location + - {/* Use My Location button */} - + {/* Theme */} +
+ +
+ {['dark', 'light', 'legacy', 'retro'].map((t) => ( + + ))} +
+
+ {themeDescriptions[theme]} +
+
- {/* Theme */} -
- -
- {['dark', 'light', 'legacy', 'retro'].map((t) => ( - - ))} -
-
- {themeDescriptions[theme]} -
-
+ {/* Layout */} +
+ +
+ {['modern', 'classic'].map((l) => ( + + ))} +
+
+ {layoutDescriptions[layout]} +
+
- {/* Layout */} -
- -
- {['modern', 'classic'].map((l) => ( -