diff --git a/package.json b/package.json
index e7c564a..fb9b221 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openhamclock",
- "version": "3.0.0",
+ "version": "3.1.0",
"description": "Open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world map",
"main": "server.js",
"scripts": {
diff --git a/public/index.html b/public/index.html
index 6fda73b..2e3f692 100644
--- a/public/index.html
+++ b/public/index.html
@@ -207,11 +207,11 @@
const { useState, useEffect, useCallback, useMemo, useRef } = React;
// ============================================
- // CONFIGURATION
+ // CONFIGURATION WITH LOCALSTORAGE
// ============================================
- const CONFIG = {
- callsign: 'K0CJH',
- location: { lat: 39.7392, lon: -104.9903 }, // Denver, CO
+ const DEFAULT_CONFIG = {
+ callsign: 'N0CALL',
+ location: { lat: 40.0150, lon: -105.2705 }, // Boulder, CO (default)
defaultDX: { lat: 35.6762, lon: 139.6503 }, // Tokyo
refreshIntervals: {
spaceWeather: 300000,
@@ -222,6 +222,29 @@
}
};
+ // Load config from localStorage or use defaults
+ const loadConfig = () => {
+ try {
+ const saved = localStorage.getItem('openhamclock_config');
+ if (saved) {
+ const parsed = JSON.parse(saved);
+ return { ...DEFAULT_CONFIG, ...parsed };
+ }
+ } catch (e) {
+ console.error('Error loading config:', e);
+ }
+ return DEFAULT_CONFIG;
+ };
+
+ // Save config to localStorage
+ const saveConfig = (config) => {
+ try {
+ localStorage.setItem('openhamclock_config', JSON.stringify(config));
+ } catch (e) {
+ console.error('Error saving config:', e);
+ }
+ };
+
// ============================================
// MAP TILE PROVIDERS
// ============================================
@@ -457,19 +480,64 @@
useEffect(() => {
const fetchDX = async () => {
try {
- // Fallback to sample data since DXWatch may have CORS issues
- setData([
- { freq: '14.074', call: 'JA1ABC', comment: 'FT8 -12dB', time: new Date().toISOString().substr(11,5)+'z' },
- { freq: '21.074', call: 'VK2DEF', comment: 'FT8 -08dB', time: new Date().toISOString().substr(11,5)+'z' },
- { freq: '7.040', call: 'DL1XYZ', comment: 'CW 599', time: new Date().toISOString().substr(11,5)+'z' },
- { freq: '14.200', call: 'ZL3QRS', comment: 'SSB 59', time: new Date().toISOString().substr(11,5)+'z' },
- { freq: '28.074', call: 'LU5TUV', comment: 'FT8 -15dB', time: new Date().toISOString().substr(11,5)+'z' }
- ]);
- } catch (err) { console.error('DX error:', err); }
+ // Try our proxy endpoint first (works when running via server.js)
+ let response = await fetch('/api/dxcluster/spots').catch(() => null);
+
+ if (response && response.ok) {
+ const spots = await response.json();
+ if (spots && spots.length > 0) {
+ setData(spots.slice(0, 15).map(s => ({
+ freq: s.frequency ? (parseFloat(s.frequency) / 1000).toFixed(3) : s.freq,
+ call: s.dx_call || s.call,
+ comment: s.comment || s.info || '',
+ time: s.time ? s.time.substr(11, 5) + 'z' : new Date().toISOString().substr(11, 5) + 'z',
+ spotter: s.spotter || ''
+ })));
+ setLoading(false);
+ return;
+ }
+ }
+
+ // Fallback: Try HamAlert/DXWatch direct (may have CORS issues)
+ response = await fetch('https://www.dxwatch.com/api/spots.json?limit=15', {
+ mode: 'cors',
+ headers: { 'Accept': 'application/json' }
+ }).catch(() => null);
+
+ if (response && response.ok) {
+ const spots = await response.json();
+ setData(spots.slice(0, 15).map(s => ({
+ freq: (parseFloat(s.frequency) / 1000).toFixed(3),
+ call: s.dx_call,
+ comment: s.comment || s.info || '',
+ time: s.time ? s.time.substr(11, 5) + 'z' : '',
+ spotter: s.spotter
+ })));
+ } else {
+ // Final fallback: PSKReporter-style data simulation based on real bands
+ // This shows the structure but indicates no live connection
+ setData([{
+ freq: '---',
+ call: 'NO DATA',
+ comment: 'Connect via server.js for live spots',
+ time: '--:--z',
+ spotter: ''
+ }]);
+ }
+ } catch (err) {
+ console.error('DX error:', err);
+ setData([{
+ freq: '---',
+ call: 'ERROR',
+ comment: 'Could not fetch DX spots',
+ time: '--:--z',
+ spotter: ''
+ }]);
+ }
finally { setLoading(false); }
};
fetchDX();
- const interval = setInterval(fetchDX, CONFIG.refreshIntervals.dxCluster);
+ const interval = setInterval(fetchDX, 30000); // 30 second refresh
return () => clearInterval(interval);
}, []);
@@ -661,7 +729,7 @@
// ============================================
// UI COMPONENTS
// ============================================
- const Header = ({ callsign, uptime, version }) => (
+ const Header = ({ callsign, uptime, version, onSettingsClick }) => (
OpenHamClock
- {callsign}
+ {callsign}
UPTIME: {uptime}
v{version}
+
@@ -847,24 +933,288 @@
);
+ // ============================================
+ // SETTINGS PANEL COMPONENT
+ // ============================================
+ const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
+ const [callsign, setCallsign] = useState(config.callsign);
+ const [lat, setLat] = useState(config.location.lat.toString());
+ const [lon, setLon] = useState(config.location.lon.toString());
+ const [gridSquare, setGridSquare] = useState('');
+ const [useGeolocation, setUseGeolocation] = useState(false);
+
+ // Calculate grid square from lat/lon
+ useEffect(() => {
+ const latNum = parseFloat(lat);
+ const lonNum = parseFloat(lon);
+ if (!isNaN(latNum) && !isNaN(lonNum)) {
+ setGridSquare(calculateGridSquare(latNum, lonNum));
+ }
+ }, [lat, lon]);
+
+ const handleGeolocation = () => {
+ if (navigator.geolocation) {
+ setUseGeolocation(true);
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ setLat(position.coords.latitude.toFixed(6));
+ setLon(position.coords.longitude.toFixed(6));
+ setUseGeolocation(false);
+ },
+ (error) => {
+ alert('Could not get location: ' + error.message);
+ setUseGeolocation(false);
+ }
+ );
+ } else {
+ alert('Geolocation is not supported by this browser');
+ }
+ };
+
+ const handleSave = () => {
+ const latNum = parseFloat(lat);
+ const lonNum = parseFloat(lon);
+
+ if (!callsign.trim()) {
+ alert('Please enter a callsign');
+ return;
+ }
+ if (isNaN(latNum) || latNum < -90 || latNum > 90) {
+ alert('Please enter a valid latitude (-90 to 90)');
+ return;
+ }
+ if (isNaN(lonNum) || lonNum < -180 || lonNum > 180) {
+ alert('Please enter a valid longitude (-180 to 180)');
+ return;
+ }
+
+ const newConfig = {
+ ...config,
+ callsign: callsign.toUpperCase().trim(),
+ location: { lat: latNum, lon: lonNum }
+ };
+
+ onSave(newConfig);
+ onClose();
+ };
+
+ const handleGridSquareChange = (gs) => {
+ setGridSquare(gs.toUpperCase());
+ // Convert grid square to lat/lon if valid (4 or 6 char)
+ if (gs.length >= 4) {
+ try {
+ const gsUpper = gs.toUpperCase();
+ const lonField = gsUpper.charCodeAt(0) - 65;
+ const latField = gsUpper.charCodeAt(1) - 65;
+ const lonSquare = parseInt(gsUpper[2]);
+ const latSquare = parseInt(gsUpper[3]);
+
+ let lonVal = (lonField * 20) + (lonSquare * 2) - 180 + 1;
+ let latVal = (latField * 10) + latSquare - 90 + 0.5;
+
+ if (gs.length >= 6) {
+ const lonSub = gsUpper.charCodeAt(4) - 65;
+ const latSub = gsUpper.charCodeAt(5) - 65;
+ lonVal = (lonField * 20) + (lonSquare * 2) + (lonSub / 12) - 180 + (1/24);
+ latVal = (latField * 10) + latSquare + (latSub / 24) - 90 + (1/48);
+ }
+
+ if (!isNaN(lonVal) && !isNaN(latVal)) {
+ setLat(latVal.toFixed(6));
+ setLon(lonVal.toFixed(6));
+ }
+ } catch (e) {
+ // Invalid grid square, ignore
+ }
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+ ⚙ Station Settings
+
+
+ {/* Callsign */}
+
+
+ setCallsign(e.target.value.toUpperCase())}
+ placeholder="W1ABC"
+ style={{
+ width: '100%', padding: '12px 16px', background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)', borderRadius: '6px',
+ color: 'var(--accent-green)', fontFamily: 'JetBrains Mono, monospace',
+ fontSize: '18px', outline: 'none', textTransform: 'uppercase'
+ }}
+ />
+
+
+ {/* Grid Square */}
+
+
+ handleGridSquareChange(e.target.value)}
+ placeholder="FN31pr"
+ maxLength={6}
+ style={{
+ width: '100%', padding: '12px 16px', background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)', borderRadius: '6px',
+ color: 'var(--accent-cyan)', fontFamily: 'JetBrains Mono, monospace',
+ fontSize: '18px', outline: 'none', textTransform: 'uppercase'
+ }}
+ />
+
+
+ {/* Lat/Lon */}
+
+
+
+ setLat(e.target.value)}
+ placeholder="40.0150"
+ style={{
+ width: '100%', padding: '10px 12px', background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)', borderRadius: '6px',
+ color: 'var(--text-primary)', fontFamily: 'JetBrains Mono, monospace',
+ fontSize: '14px', outline: 'none'
+ }}
+ />
+
+
+
+ setLon(e.target.value)}
+ placeholder="-105.2705"
+ style={{
+ width: '100%', padding: '10px 12px', background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)', borderRadius: '6px',
+ color: 'var(--text-primary)', fontFamily: 'JetBrains Mono, monospace',
+ fontSize: '14px', outline: 'none'
+ }}
+ />
+
+
+
+ {/* Get Location Button */}
+
+
+ {/* Buttons */}
+
+
+
+
+
+
+ Settings are saved in your browser
+
+
+
+ );
+ };
+
// ============================================
// MAIN APP
// ============================================
const App = () => {
+ const [config, setConfig] = useState(loadConfig);
const [currentTime, setCurrentTime] = useState(new Date());
const [startTime] = useState(Date.now());
const [uptime, setUptime] = useState('0d 0h 0m');
- const [deLocation] = useState(CONFIG.location);
- const [dxLocation, setDxLocation] = useState(CONFIG.defaultDX);
+ const [dxLocation, setDxLocation] = useState(config.defaultDX);
+ const [showSettings, setShowSettings] = useState(false);
+
+ // Check if this is first run (no config saved)
+ useEffect(() => {
+ const saved = localStorage.getItem('openhamclock_config');
+ if (!saved) {
+ // First time user - show settings
+ setShowSettings(true);
+ }
+ }, []);
+
+ const handleSaveConfig = (newConfig) => {
+ setConfig(newConfig);
+ saveConfig(newConfig);
+ };
const spaceWeather = useSpaceWeather();
const bandConditions = useBandConditions();
const potaSpots = usePOTASpots();
const dxCluster = useDXCluster();
- const deGrid = useMemo(() => calculateGridSquare(deLocation.lat, deLocation.lon), [deLocation]);
+ const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]);
const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]);
- const deSunTimes = useMemo(() => calculateSunTimes(deLocation.lat, deLocation.lon, currentTime), [deLocation, currentTime]);
+ const deSunTimes = useMemo(() => calculateSunTimes(config.location.lat, config.location.lon, currentTime), [config.location, currentTime]);
const dxSunTimes = useMemo(() => calculateSunTimes(dxLocation.lat, dxLocation.lon, currentTime), [dxLocation, currentTime]);
useEffect(() => {
@@ -890,23 +1240,23 @@
return (
-
+
setShowSettings(true)} />
{/* Row 1 */}
-
+
{/* Row 2: Map */}
-
+
Click anywhere on map to set DX location • Use buttons to change map style
-
+
@@ -917,8 +1267,16 @@
+
+ {/* Settings Panel */}
+ setShowSettings(false)}
+ config={config}
+ onSave={handleSaveConfig}
+ />
);
};
diff --git a/server.js b/server.js
index 0092bc2..7e5afaf 100644
--- a/server.js
+++ b/server.js
@@ -115,24 +115,95 @@ app.get('/api/hamqsl/conditions', async (req, res) => {
}
});
-// DX Cluster proxy (for future WebSocket implementation)
+// DX Cluster proxy - fetches from multiple sources
app.get('/api/dxcluster/spots', async (req, res) => {
try {
// Try DXWatch first
- const response = await fetch('https://www.dxwatch.com/api/spots.json?limit=20', {
- headers: { 'User-Agent': 'OpenHamClock/3.0' }
+ const response = await fetch('https://dxwatch.com/dxsd1/s.php?s=0&r=50&cdx=', {
+ headers: {
+ 'User-Agent': 'OpenHamClock/3.0',
+ 'Accept': 'application/json'
+ },
+ timeout: 5000
});
+
+ if (response.ok) {
+ const text = await response.text();
+ try {
+ // DXWatch returns JSON array
+ const data = JSON.parse(text);
+ const spots = data.map(spot => ({
+ freq: spot.fr ? (parseFloat(spot.fr) / 1000).toFixed(3) : spot.frequency,
+ call: spot.dx || spot.dx_call,
+ comment: spot.cm || spot.comment || '',
+ time: spot.t || spot.time || '',
+ spotter: spot.sp || spot.spotter
+ })).slice(0, 20);
+ return res.json(spots);
+ } catch (parseErr) {
+ console.log('DXWatch parse error, trying alternate format');
+ }
+ }
+ } catch (error) {
+ console.error('DXWatch API error:', error.message);
+ }
+
+ // Try HamQTH DX Cluster as fallback
+ try {
+ const response = await fetch('https://www.hamqth.com/dxc_csv.php?limit=25', {
+ headers: { 'User-Agent': 'OpenHamClock/3.0' },
+ timeout: 5000
+ });
+
+ if (response.ok) {
+ const text = await response.text();
+ const lines = text.trim().split('\n');
+ const spots = lines.slice(0, 20).map(line => {
+ const parts = line.split(',');
+ return {
+ freq: parts[1] ? (parseFloat(parts[1]) / 1000).toFixed(3) : '0.000',
+ call: parts[2] || 'UNKNOWN',
+ comment: parts[5] || '',
+ time: parts[4] ? parts[4].substring(0, 5) + 'z' : '',
+ spotter: parts[3] || ''
+ };
+ }).filter(s => s.call !== 'UNKNOWN');
+
+ if (spots.length > 0) {
+ return res.json(spots);
+ }
+ }
+ } catch (error) {
+ console.error('HamQTH DX Cluster error:', error.message);
+ }
+
+ // Try DX Summit RSS as another fallback
+ try {
+ const response = await fetch('https://www.dxsummit.fi/api/v1/spots?limit=25', {
+ headers: { 'User-Agent': 'OpenHamClock/3.0' },
+ timeout: 5000
+ });
+
if (response.ok) {
const data = await response.json();
- res.json(data);
- } else {
- // Return empty array if API unavailable
- res.json([]);
+ const spots = data.map(spot => ({
+ freq: spot.frequency ? (parseFloat(spot.frequency) / 1000).toFixed(3) : '0.000',
+ call: spot.dx_call || spot.callsign,
+ comment: spot.info || spot.comment || '',
+ time: spot.time ? spot.time.substring(11, 16) + 'z' : '',
+ spotter: spot.spotter || ''
+ })).slice(0, 20);
+
+ if (spots.length > 0) {
+ return res.json(spots);
+ }
}
} catch (error) {
- console.error('DX Cluster API error:', error.message);
- res.json([]); // Return empty array on error
+ console.error('DX Summit API error:', error.message);
}
+
+ // Return empty array if all sources fail
+ res.json([]);
});
// QRZ Callsign lookup (requires API key)