|
|
|
|
@ -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 }) => (
|
|
|
|
|
<header style={{
|
|
|
|
|
background: 'linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%)',
|
|
|
|
|
borderBottom: '1px solid var(--border-color)',
|
|
|
|
|
@ -675,16 +743,34 @@
|
|
|
|
|
fontFamily: 'Orbitron, monospace', fontSize: '28px', fontWeight: '700',
|
|
|
|
|
color: 'var(--accent-amber)', textShadow: '0 0 10px var(--accent-amber-dim)', letterSpacing: '2px'
|
|
|
|
|
}}>OpenHamClock</div>
|
|
|
|
|
<div style={{
|
|
|
|
|
<div
|
|
|
|
|
onClick={onSettingsClick}
|
|
|
|
|
style={{
|
|
|
|
|
background: 'var(--bg-tertiary)', padding: '6px 16px', borderRadius: '4px',
|
|
|
|
|
border: '1px solid var(--border-color)', fontFamily: 'JetBrains Mono, monospace',
|
|
|
|
|
fontSize: '16px', fontWeight: '600', color: 'var(--accent-green)',
|
|
|
|
|
textShadow: '0 0 8px var(--accent-green-dim)'
|
|
|
|
|
}}>{callsign}</div>
|
|
|
|
|
textShadow: '0 0 8px var(--accent-green-dim)', cursor: 'pointer',
|
|
|
|
|
transition: 'all 0.2s'
|
|
|
|
|
}}
|
|
|
|
|
title="Click to change settings"
|
|
|
|
|
>{callsign}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '24px', fontFamily: 'JetBrains Mono, monospace', fontSize: '12px', color: 'var(--text-secondary)' }}>
|
|
|
|
|
<span>UPTIME: {uptime}</span>
|
|
|
|
|
<span style={{ color: 'var(--accent-cyan)' }}>v{version}</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onSettingsClick}
|
|
|
|
|
style={{
|
|
|
|
|
background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)',
|
|
|
|
|
borderRadius: '6px', padding: '8px 12px', color: 'var(--text-secondary)',
|
|
|
|
|
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px',
|
|
|
|
|
fontFamily: 'JetBrains Mono, monospace', fontSize: '11px',
|
|
|
|
|
transition: 'all 0.2s'
|
|
|
|
|
}}
|
|
|
|
|
title="Settings"
|
|
|
|
|
>
|
|
|
|
|
⚙ Settings
|
|
|
|
|
</button>
|
|
|
|
|
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: 'var(--accent-green)', boxShadow: '0 0 8px var(--accent-green)', animation: 'pulse 2s infinite' }} />
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
@ -847,24 +933,288 @@
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
|
// 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 (
|
|
|
|
|
<div style={{
|
|
|
|
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
|
|
|
|
background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
|
|
|
zIndex: 10000, backdropFilter: 'blur(5px)'
|
|
|
|
|
}} onClick={onClose}>
|
|
|
|
|
<div style={{
|
|
|
|
|
background: 'var(--bg-secondary)', border: '1px solid var(--border-color)',
|
|
|
|
|
borderRadius: '12px', padding: '30px', width: '90%', maxWidth: '450px',
|
|
|
|
|
boxShadow: '0 20px 60px rgba(0,0,0,0.5)'
|
|
|
|
|
}} onClick={e => e.stopPropagation()}>
|
|
|
|
|
<h2 style={{
|
|
|
|
|
fontFamily: 'Orbitron, monospace', fontSize: '24px', color: 'var(--accent-amber)',
|
|
|
|
|
marginBottom: '24px', textAlign: 'center'
|
|
|
|
|
}}>
|
|
|
|
|
⚙ Station Settings
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
{/* Callsign */}
|
|
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
|
|
|
<label style={{ display: 'block', color: 'var(--text-secondary)', fontSize: '12px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
|
|
|
|
Your Callsign
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={callsign}
|
|
|
|
|
onChange={(e) => 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'
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Grid Square */}
|
|
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
|
|
|
<label style={{ display: 'block', color: 'var(--text-secondary)', fontSize: '12px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
|
|
|
|
Grid Square (or enter lat/lon below)
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={gridSquare}
|
|
|
|
|
onChange={(e) => 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'
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Lat/Lon */}
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '20px' }}>
|
|
|
|
|
<div>
|
|
|
|
|
<label style={{ display: 'block', color: 'var(--text-secondary)', fontSize: '12px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
|
|
|
|
Latitude
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={lat}
|
|
|
|
|
onChange={(e) => 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'
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label style={{ display: 'block', color: 'var(--text-secondary)', fontSize: '12px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
|
|
|
|
Longitude
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={lon}
|
|
|
|
|
onChange={(e) => 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'
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Get Location Button */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleGeolocation}
|
|
|
|
|
disabled={useGeolocation}
|
|
|
|
|
style={{
|
|
|
|
|
width: '100%', padding: '12px', background: 'var(--bg-tertiary)',
|
|
|
|
|
border: '1px solid var(--border-color)', borderRadius: '6px',
|
|
|
|
|
color: 'var(--text-secondary)', fontFamily: 'JetBrains Mono, monospace',
|
|
|
|
|
fontSize: '12px', cursor: 'pointer', marginBottom: '24px',
|
|
|
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{useGeolocation ? (
|
|
|
|
|
<><div className="loading-spinner" /> Getting location...</>
|
|
|
|
|
) : (
|
|
|
|
|
<>📍 Use My Current Location</>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Buttons */}
|
|
|
|
|
<div style={{ display: 'flex', gap: '12px' }}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
style={{
|
|
|
|
|
flex: 1, padding: '14px', background: 'var(--bg-tertiary)',
|
|
|
|
|
border: '1px solid var(--border-color)', borderRadius: '6px',
|
|
|
|
|
color: 'var(--text-secondary)', fontFamily: 'Space Grotesk, sans-serif',
|
|
|
|
|
fontSize: '14px', fontWeight: '600', cursor: 'pointer'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSave}
|
|
|
|
|
style={{
|
|
|
|
|
flex: 1, padding: '14px', background: 'var(--accent-amber)',
|
|
|
|
|
border: 'none', borderRadius: '6px',
|
|
|
|
|
color: '#000', fontFamily: 'Space Grotesk, sans-serif',
|
|
|
|
|
fontSize: '14px', fontWeight: '700', cursor: 'pointer'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Save Settings
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p style={{
|
|
|
|
|
marginTop: '20px', fontSize: '11px', color: 'var(--text-muted)',
|
|
|
|
|
textAlign: 'center', fontFamily: 'JetBrains Mono, monospace'
|
|
|
|
|
}}>
|
|
|
|
|
Settings are saved in your browser
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
|
// 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 (
|
|
|
|
|
<div style={{ minHeight: '100vh', background: 'var(--bg-primary)' }}>
|
|
|
|
|
<Header callsign={CONFIG.callsign} uptime={uptime} version="3.0.0" />
|
|
|
|
|
<Header callsign={config.callsign} uptime={uptime} version="3.1.0" onSettingsClick={() => setShowSettings(true)} />
|
|
|
|
|
|
|
|
|
|
<main style={{ padding: '20px', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gridTemplateRows: 'auto 1fr auto', gap: '16px', maxWidth: '1800px', margin: '0 auto', minHeight: 'calc(100vh - 120px)' }}>
|
|
|
|
|
{/* Row 1 */}
|
|
|
|
|
<ClockPanel label="UTC Time" time={utcTime} date={utcDate} isUtc={true} />
|
|
|
|
|
<ClockPanel label="Local Time" time={localTime} date={localDate} isUtc={false} />
|
|
|
|
|
<LocationPanel type="DE" location={deLocation} gridSquare={deGrid} sunTimes={deSunTimes} otherLocation={dxLocation} />
|
|
|
|
|
<LocationPanel type="DE" location={config.location} gridSquare={deGrid} sunTimes={deSunTimes} otherLocation={dxLocation} />
|
|
|
|
|
|
|
|
|
|
{/* Row 2: Map */}
|
|
|
|
|
<div style={{ gridColumn: 'span 2', minHeight: '350px' }}>
|
|
|
|
|
<WorldMap deLocation={deLocation} dxLocation={dxLocation} onDXChange={handleDXChange} potaSpots={potaSpots.data} />
|
|
|
|
|
<WorldMap deLocation={config.location} dxLocation={dxLocation} onDXChange={handleDXChange} potaSpots={potaSpots.data} />
|
|
|
|
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '8px', fontFamily: 'JetBrains Mono', textAlign: 'center' }}>
|
|
|
|
|
Click anywhere on map to set DX location • Use buttons to change map style
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
|
|
|
<LocationPanel type="DX" location={dxLocation} gridSquare={dxGrid} sunTimes={dxSunTimes} otherLocation={deLocation} />
|
|
|
|
|
<LocationPanel type="DX" location={dxLocation} gridSquare={dxGrid} sunTimes={dxSunTimes} otherLocation={config.location} />
|
|
|
|
|
<SpaceWeatherPanel data={spaceWeather.data} loading={spaceWeather.loading} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@ -917,8 +1267,16 @@
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
<footer style={{ textAlign: 'center', padding: '20px', color: 'var(--text-muted)', fontSize: '11px', fontFamily: 'JetBrains Mono, monospace' }}>
|
|
|
|
|
OpenHamClock v3.0.0 | In memory of Elwood Downey WB0OEW | Map tiles © OpenStreetMap, ESRI, CARTO | 73 de {CONFIG.callsign}
|
|
|
|
|
OpenHamClock v3.1.0 | In memory of Elwood Downey WB0OEW | Map tiles © OpenStreetMap, ESRI, CARTO | 73 de {config.callsign}
|
|
|
|
|
</footer>
|
|
|
|
|
|
|
|
|
|
{/* Settings Panel */}
|
|
|
|
|
<SettingsPanel
|
|
|
|
|
isOpen={showSettings}
|
|
|
|
|
onClose={() => setShowSettings(false)}
|
|
|
|
|
config={config}
|
|
|
|
|
onSave={handleSaveConfig}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|