diff --git a/package.json b/package.json
index 122c622..ff37c72 100644
--- a/package.json
+++ b/package.json
@@ -18,8 +18,11 @@
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
+ "i18next": "^25.8.0",
+ "i18next-browser-languagedetector": "^8.2.0",
"mqtt": "^5.3.4",
"node-fetch": "^2.7.0",
+ "react-i18next": "^16.5.4",
"satellite.js": "^5.0.0",
"ws": "^8.14.2"
},
@@ -41,4 +44,4 @@
],
"author": "K0CJH",
"license": "MIT"
-}
+}
\ No newline at end of file
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 73ef1b9..63770fc 100644
--- a/src/components/SettingsPanel.jsx
+++ b/src/components/SettingsPanel.jsx
@@ -1,14 +1,11 @@
/**
* 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';
-<<<<<<< Updated upstream
-=======
import { useTranslation, Trans } from 'react-i18next';
import { LANGUAGES } from '../lang/i18n.js';
->>>>>>> Stashed changes
export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [callsign, setCallsign] = useState(config?.callsign || '');
@@ -18,14 +15,11 @@ 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');
-<<<<<<< Updated upstream
-=======
const { t, i18n } = useTranslation();
// Layer controls
const [layers, setLayers] = useState([]);
const [activeTab, setActiveTab] = useState('station');
->>>>>>> Stashed changes
useEffect(() => {
if (config) {
@@ -35,19 +29,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) {
@@ -57,7 +65,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;
@@ -80,7 +87,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));
@@ -96,11 +102,42 @@ 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'));
+ }
+ };
+
+ 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]);
}
};
@@ -108,7 +145,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
onSave({
...config,
callsign: callsign.toUpperCase(),
- locator: gridSquare.toUpperCase(),
location: { lat: parseFloat(lat), lon: parseFloat(lon) },
theme,
layout,
@@ -119,16 +155,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 (
@@ -149,186 +191,285 @@ 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
+ {t('station.settings.title')}
- {/* 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' && (
+ <>
+ {/* First-time setup banner */}
+ {(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && (
+
+
+ {t("station.settings.welcome")}
+
+
+ {t("station.settings.describe")}
+
+
+ , env: }} />
+
+
+ )}
+
+ {/* 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) || 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) || 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'
+ }}
+ />
+
+
+
+
-
-
+ >
+ {t('station.settings.useLocation')}
+
- {/* Use My Location button */}
-
+ {/* Theme */}
+
+
+
+ {['dark', 'light', 'legacy', 'retro'].map((th) => (
+
+ ))}
+
+
+ {themeDescriptions[theme]}
+
+
- {/* Theme */}
-
-
-
- {['dark', 'light', 'legacy', 'retro'].map((t) => (
-
-
- {themeDescriptions[theme]}
-
-
-
- {/* Layout */}
-
-
-
- {['modern', 'classic'].map((l) => (
- setLayout(l)}
- style={{
- padding: '10px',
- background: layout === l ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
- border: `1px solid ${layout === l ? 'var(--accent-amber)' : 'var(--border-color)'}`,
- borderRadius: '6px',
- color: layout === l ? '#000' : 'var(--text-secondary)',
- fontSize: '13px',
- cursor: 'pointer',
- fontWeight: layout === l ? '600' : '400'
- }}
- >
- {l === 'modern' ? '🖥️' : '📺'} {l.charAt(0).toUpperCase() + l.slice(1)}
-
- ))}
-
- {layoutDescriptions[layout]}
-
-
-
- {/* DX Cluster Source */}
-
-
-
-
- → Real-time DX Spider feed via our dedicated proxy service
-
-
+ )}
{/* Buttons */}
@@ -556,7 +633,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
cursor: 'pointer'
}}
>
- Cancel
+ {t('cancel')}
{
cursor: 'pointer'
}}
>
- Save Settings
+ {t('station.settings.button.save')}
- Settings are saved in your browser
+ {t('station.settings.button.save.confirm')}
diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx
index de8181e..b7043e6 100644
--- a/src/components/WorldMap.jsx
+++ b/src/components/WorldMap.jsx
@@ -1,6 +1,6 @@
/**
* WorldMap Component
- * Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites
+ * Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites, PSKReporter
*/
import React, { useRef, useEffect, useState } from 'react';
import { MAP_STYLES } from '../utils/config.js';
@@ -12,6 +12,10 @@ import {
} from '../utils/geo.js';
import { filterDXPaths, getBandColor } from '../utils/callsign.js';
+import { getAllLayers } from '../plugins/layerRegistry.js';
+import PluginLayer from './PluginLayer.jsx';
+
+
export const WorldMap = ({
deLocation,
dxLocation,
@@ -47,6 +51,10 @@ export const WorldMap = ({
const satMarkersRef = useRef([]);
const satTracksRef = useRef([]);
const pskMarkersRef = useRef([]);
+
+ // Plugin system refs and state
+ const pluginLayersRef = useRef({});
+ const [pluginLayerStates, setPluginLayerStates] = useState({});
// Load map style from localStorage
const getStoredMapSettings = () => {
@@ -419,6 +427,84 @@ export const WorldMap = ({
}
}, [satellites, showSatellites]);
+ // Plugin layer system - properly load saved states
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+
+ try {
+ const availableLayers = getAllLayers();
+ const settings = getStoredMapSettings();
+ const savedLayers = settings.layers || {};
+
+ // Build initial states from localStorage
+ const initialStates = {};
+ availableLayers.forEach(layerDef => {
+ // Use saved state if it exists, otherwise use defaults
+ if (savedLayers[layerDef.id]) {
+ initialStates[layerDef.id] = savedLayers[layerDef.id];
+ } else {
+ initialStates[layerDef.id] = {
+ enabled: layerDef.defaultEnabled,
+ opacity: layerDef.defaultOpacity
+ };
+ }
+ });
+
+ // Initialize state ONLY on first mount (when empty)
+ if (Object.keys(pluginLayerStates).length === 0) {
+ console.log('Loading saved layer states:', initialStates);
+ setPluginLayerStates(initialStates);
+ }
+
+ // Expose controls for SettingsPanel
+ window.hamclockLayerControls = {
+ layers: availableLayers.map(l => ({
+ ...l,
+ enabled: pluginLayerStates[l.id]?.enabled ?? initialStates[l.id]?.enabled ?? l.defaultEnabled,
+ opacity: pluginLayerStates[l.id]?.opacity ?? initialStates[l.id]?.opacity ?? l.defaultOpacity
+ })),
+ toggleLayer: (id, enabled) => {
+ console.log(`Toggle layer ${id}:`, enabled);
+ const settings = getStoredMapSettings();
+ const layers = settings.layers || {};
+ layers[id] = {
+ enabled: enabled,
+ opacity: layers[id]?.opacity ?? 0.6
+ };
+ localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ ...settings, layers }));
+ console.log('Saved to localStorage:', layers);
+ setPluginLayerStates(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ enabled: enabled
+ }
+ }));
+ },
+ setOpacity: (id, opacity) => {
+ console.log(`Set opacity ${id}:`, opacity);
+ const settings = getStoredMapSettings();
+ const layers = settings.layers || {};
+ layers[id] = {
+ enabled: layers[id]?.enabled ?? false,
+ opacity: opacity
+ };
+ localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ ...settings, layers }));
+ console.log('Saved to localStorage:', layers);
+ setPluginLayerStates(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ opacity: opacity
+ }
+ }));
+ }
+ };
+ } catch (err) {
+ console.error('Plugin system error:', err);
+ }
+ }, [pluginLayerStates]);
+
// Update PSKReporter markers
useEffect(() => {
if (!mapInstanceRef.current) return;
@@ -488,6 +574,17 @@ export const WorldMap = ({
+ {/* Render all plugin layers */}
+ {mapInstanceRef.current && getAllLayers().map(layerDef => (
+
+ ))}
+
{/* Map style dropdown */}