feat: status bar added
This commit is contained in:
+389
-192
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, ExternalLink, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Send, Settings, Square, Trash2, Unlock, X,
|
||||
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -14,24 +14,25 @@ import {
|
||||
SetCompactMode,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
||||
RefreshCtyDat,
|
||||
RotatorGoTo, RotatorStop,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||
OpenExternalURL,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||
ListClusterServers, ClusterSpotStatuses,
|
||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||
GetCATSettings,
|
||||
OperatingDefaultForBand,
|
||||
LogUDPLoggedADIF,
|
||||
ListCountries,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { EventsOn } from '../wailsjs/runtime/runtime';
|
||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
|
||||
import { Menubar, type Menu } from '@/components/Menubar';
|
||||
import { QSLManagerModal } from '@/components/QSLManagerModal';
|
||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
import { BandMap } from '@/components/BandMap';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
@@ -39,6 +40,7 @@ import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -54,6 +56,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { pathBetween } from '@/lib/maidenhead';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
|
||||
type QSO = QSOForm;
|
||||
type ImportResult = adifModels.ImportResult;
|
||||
@@ -141,6 +144,38 @@ function shortCatError(err?: string): string {
|
||||
if (e.includes('coinitialize')) return 'COM error';
|
||||
return err.length > 24 ? err.slice(0, 22) + '…' : err;
|
||||
}
|
||||
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' if outside
|
||||
// every known allocation (used to auto-fill the band when the freq changes).
|
||||
function bandForMHz(mhz: number): string {
|
||||
if (!mhz || isNaN(mhz)) return '';
|
||||
const plan: [number, number, string][] = [
|
||||
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
|
||||
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
|
||||
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
|
||||
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [1240, 1300, '23cm'],
|
||||
];
|
||||
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
|
||||
return '';
|
||||
}
|
||||
|
||||
// rstCategory buckets a mode into the report family used for its RST list.
|
||||
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
|
||||
function rstCategory(mode: string): keyof RSTLists {
|
||||
const m = (mode || '').toUpperCase();
|
||||
const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
|
||||
if (digital.includes(m)) return 'digital';
|
||||
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
|
||||
return 'phone';
|
||||
}
|
||||
// rstOptions returns the valid report choices for a mode from the user's
|
||||
// editable lists (Settings → Modes), with a tiny fallback before they load.
|
||||
function rstOptions(mode: string, lists: RSTLists): string[] {
|
||||
const cat = rstCategory(mode);
|
||||
const l = lists[cat];
|
||||
if (l && l.length) return l;
|
||||
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
|
||||
}
|
||||
|
||||
function computePrefix(call: string): string {
|
||||
if (!call) return '';
|
||||
const c = call.trim().toUpperCase().split('/')[0];
|
||||
@@ -159,6 +194,8 @@ export default function App() {
|
||||
|
||||
// === Entry ===
|
||||
const [callsign, setCallsign] = useState('');
|
||||
// Ref to the callsign input so ESC can snap focus back to it.
|
||||
const callsignRef = useRef<HTMLInputElement>(null);
|
||||
// QSO start time — frozen when the operator starts typing the callsign,
|
||||
// logged as ADIF QSO_DATE. End time = save click (logged as QSO_DATE_OFF).
|
||||
const [qsoStartedAt, setQsoStartedAt] = useState<Date | null>(null);
|
||||
@@ -228,6 +265,10 @@ export default function App() {
|
||||
const [freqMhz, setFreqMhz] = useState('');
|
||||
// RX freq for split — only set/shown when the rig is in split mode.
|
||||
const [rxFreqMhz, setRxFreqMhz] = useState('');
|
||||
// RX band — follows the TX band by default; only differs for cross-band work.
|
||||
const [bandRx, setBandRx] = useState('20m');
|
||||
const [countries, setCountries] = useState<string[]>([]);
|
||||
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
||||
const [rstSent, setRstSent] = useState('59');
|
||||
const [rstRcvd, setRstRcvd] = useState('59');
|
||||
const [grid, setGrid] = useState('');
|
||||
@@ -248,6 +289,7 @@ export default function App() {
|
||||
|
||||
// CAT — receives live rig state via Wails events.
|
||||
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
||||
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
||||
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||
// in Preferences > Hardware > CAT interface.
|
||||
@@ -360,11 +402,24 @@ export default function App() {
|
||||
const [qsos, setQsos] = useState<QSO[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [error, setError] = useState('');
|
||||
// Transient success toast (bottom-right, auto-dismiss). Used for things
|
||||
// like "spot sent" where a blocking error banner would be overkill.
|
||||
const [toast, setToast] = useState('');
|
||||
const showToast = useCallback((msg: string) => {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500);
|
||||
}, []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [filterCallsign, setFilterCallsign] = useState('');
|
||||
const [filterBand, setFilterBand] = useState('');
|
||||
const [filterMode, setFilterMode] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('recent');
|
||||
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
||||
const [qslTabOpen, setQslTabOpen] = useState(false);
|
||||
function closeQslTab() {
|
||||
setQslTabOpen(false);
|
||||
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
|
||||
}
|
||||
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
|
||||
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
||||
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
||||
@@ -410,6 +465,8 @@ export default function App() {
|
||||
retries?: number;
|
||||
};
|
||||
const [clusterServerStatuses, setClusterServerStatuses] = useState<ServerStatus[]>([]);
|
||||
// "Send DX spot" dialog (Log4OM-style) — only reachable when a cluster is up.
|
||||
const [showSpotModal, setShowSpotModal] = useState(false);
|
||||
const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]);
|
||||
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
|
||||
const [spots, setSpots] = useState<ClusterSpot[]>([]);
|
||||
@@ -449,7 +506,6 @@ export default function App() {
|
||||
// close so the next plain "Preferences" launch reverts to default.
|
||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||
const [showQSLManager, setShowQSLManager] = useState(false);
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||
|
||||
@@ -518,6 +574,44 @@ export default function App() {
|
||||
return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); };
|
||||
}, [refresh]);
|
||||
|
||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||
// rotator is disabled (the backend just reads settings and returns).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 3000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// RX band auto-follows the TX band (only differs for cross-band work).
|
||||
useEffect(() => { setBandRx(band); }, [band]);
|
||||
|
||||
// RX freq mirrors TX freq on every TX change (unless the rig is in split,
|
||||
// where the RX freq is genuinely different). It stays editable by hand:
|
||||
// a manual RX edit sticks until the next TX-freq change re-syncs it.
|
||||
useEffect(() => {
|
||||
if (!catState.split) setRxFreqMhz(freqMhz);
|
||||
}, [freqMhz, catState.split]);
|
||||
|
||||
// Load the DXCC country list for the Country picker. cty.dat loads a few
|
||||
// seconds after startup, so retry until it's available.
|
||||
useEffect(() => {
|
||||
let tries = 0;
|
||||
let timer = 0;
|
||||
const load = async () => {
|
||||
try {
|
||||
const c = await ListCountries();
|
||||
if (c && c.length) { setCountries(c); return; }
|
||||
} catch {}
|
||||
if (tries++ < 15) timer = window.setTimeout(load, 2000);
|
||||
};
|
||||
load();
|
||||
return () => { if (timer) window.clearTimeout(timer); };
|
||||
}, []);
|
||||
|
||||
const loadStation = useCallback(async () => {
|
||||
try { setStation(await GetStationSettings()); } catch {}
|
||||
}, []);
|
||||
@@ -530,6 +624,7 @@ export default function App() {
|
||||
const loadLists = useCallback(async () => {
|
||||
try {
|
||||
const l: ListsSettings = await GetListsSettings();
|
||||
setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] });
|
||||
if (l.bands && l.bands.length) setBands(l.bands);
|
||||
if (l.modes && l.modes.length) {
|
||||
setModePresets(l.modes);
|
||||
@@ -581,14 +676,14 @@ export default function App() {
|
||||
if (!lk.freq && s.freq_hz && s.freq_hz > 0) {
|
||||
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
||||
}
|
||||
// RX freq (split only): backend follows ADIF — freq_hz = TX,
|
||||
// freq_rx_hz = RX. Only set when the rig is in split, otherwise the
|
||||
// field would duplicate TX for no reason. The freq lock covers both.
|
||||
// RX freq: backend follows ADIF — freq_hz = TX, freq_rx_hz = RX.
|
||||
// In split we take the rig's real RX freq; otherwise RX mirrors TX
|
||||
// (the user can still override it by hand). The freq lock covers both.
|
||||
if (!lk.freq) {
|
||||
if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) {
|
||||
setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5));
|
||||
} else {
|
||||
setRxFreqMhz('');
|
||||
} else if (s.freq_hz && s.freq_hz > 0) {
|
||||
setRxFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
||||
}
|
||||
}
|
||||
if (!lk.band && s.band) setBand(s.band);
|
||||
@@ -736,7 +831,7 @@ export default function App() {
|
||||
callsign: callsign.trim().toUpperCase(),
|
||||
qso_date: start.toISOString(),
|
||||
qso_date_off: end.toISOString(),
|
||||
band, mode, freq_hz: freqHz, freq_rx_hz: rxFreqHz,
|
||||
band, band_rx: bandRx, mode, freq_hz: freqHz, freq_rx_hz: rxFreqHz,
|
||||
rst_sent: rstSent, rst_rcvd: rstRcvd,
|
||||
grid: grid.trim().toUpperCase(),
|
||||
name, qth, country, comment, notes: note,
|
||||
@@ -981,9 +1076,8 @@ export default function App() {
|
||||
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' },
|
||||
{ type: 'item', label: 'DX Cluster', action: 'tools.cluster', disabled: true },
|
||||
{ type: 'item', label: 'CAT control', action: 'tools.cat', disabled: true },
|
||||
{ type: 'item', label: 'Rotator', action: 'tools.rotator', disabled: true },
|
||||
{ type: 'item', label: 'CAT interface…', action: 'tools.cat' },
|
||||
{ type: 'item', label: 'Rotator…', action: 'tools.rotator' },
|
||||
{ type: 'separator' },
|
||||
// Maintenance — bumped here while we only have one entry. Will move
|
||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
||||
@@ -1004,8 +1098,10 @@ export default function App() {
|
||||
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
||||
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setShowQSLManager(true); break;
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break;
|
||||
case 'tools.cat': setSettingsSection('cat'); setShowSettings(true); break;
|
||||
case 'tools.rotator': setSettingsSection('rotator'); setShowSettings(true); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
}
|
||||
}
|
||||
@@ -1097,9 +1193,20 @@ export default function App() {
|
||||
{catState.split && (
|
||||
<Badge className="bg-amber-100 text-amber-800 border-amber-300 font-mono text-[10px] py-0" variant="outline">SPLIT</Badge>
|
||||
)}
|
||||
{/* Band & mode removed here — shown in the QSO entry strip below. */}
|
||||
{catState.enabled && catState.backend === 'omnirig' && (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden text-[11px] font-sans font-semibold">
|
||||
{[1, 2].map((n) => {
|
||||
const active = (catState.rig_num || 1) === n;
|
||||
return (
|
||||
<button key={n} type="button" onClick={() => SwitchCATRig(n).catch(() => {})}
|
||||
className={cn('px-2 py-0.5 transition-colors', active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}
|
||||
title={`Use OmniRig Rig ${n}`}>R{n}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-px h-4 bg-border mx-2" />
|
||||
<Badge variant="accent" className="font-mono">{band}</Badge>
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono" variant="outline">{mode}</Badge>
|
||||
{/* Bearing controls — three separate buttons so SP and LP are
|
||||
both directly clickable, plus an always-visible Stop. The
|
||||
old Shift/Ctrl shortcuts were not discoverable enough. */}
|
||||
@@ -1151,51 +1258,6 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{catState.enabled && (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold font-sans ml-2 border',
|
||||
catState.connected
|
||||
? 'bg-emerald-100 text-emerald-800 border-emerald-300'
|
||||
: 'bg-rose-100 text-rose-800 border-rose-300',
|
||||
)}
|
||||
title={
|
||||
catState.connected
|
||||
? `CAT: ${catState.rig || catState.backend || 'connected'}`
|
||||
: catState.error || 'CAT offline'
|
||||
}
|
||||
>
|
||||
<RadioTower className={cn('size-3', catState.connected && 'animate-pulse')} />
|
||||
{catState.connected
|
||||
? (catState.rig || 'CAT')
|
||||
: (shortCatError(catState.error) || 'CAT off')}
|
||||
</span>
|
||||
{catState.backend === 'omnirig' && (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden text-[10px] font-sans font-semibold">
|
||||
{[1, 2].map((n) => {
|
||||
const active = (catState.rig_num || 1) === n;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => SwitchCATRig(n).catch(() => {})}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 transition-colors',
|
||||
active
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-card text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
title={`Use OmniRig Rig ${n}`}
|
||||
>
|
||||
R{n}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||
@@ -1251,12 +1313,23 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transient success toast (bottom-right). */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
<Satellite className="size-4 shrink-0" />
|
||||
<span>{toast}</span>
|
||||
<button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== ENTRY STRIP =====
|
||||
Enter from any <input> inside the strip logs the QSO. Radix Selects
|
||||
render as <button> elements and are ignored by this handler — they
|
||||
keep their own keyboard behaviour. */}
|
||||
<div className={cn(!compact && 'flex gap-2.5 items-stretch px-2.5 pt-2.5 shrink-0')}>
|
||||
<section
|
||||
className="flex gap-2 items-end flex-wrap px-3 py-2 bg-card border-b border-border shadow-sm shrink-0"
|
||||
className={cn('flex gap-2 items-end flex-wrap content-start px-3 py-2 bg-card shadow-sm border-border',
|
||||
compact ? 'border-b shrink-0' : 'flex-1 min-w-[560px] max-w-[920px] border rounded-lg')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
|
||||
e.preventDefault();
|
||||
@@ -1264,9 +1337,12 @@ export default function App() {
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
resetEntry();
|
||||
// Snap focus back to the callsign field, ready for the next QSO.
|
||||
callsignRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* ── Row 1: Callsign + RST ── */}
|
||||
<div className="flex flex-col w-40">
|
||||
<Label className="mb-1 flex items-center gap-2 h-3.5">
|
||||
Callsign
|
||||
@@ -1294,60 +1370,25 @@ export default function App() {
|
||||
{!lookupBusy && !lookupResult && lookupError && (
|
||||
<Badge variant="destructive" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider">{lookupError}</Badge>
|
||||
)}
|
||||
{/* Contacted entity flag (from its DXCC number). */}
|
||||
{flagURL(details.dxcc) && (
|
||||
<img src={flagURL(details.dxcc)} alt="" title={country}
|
||||
className="h-3.5 ml-auto rounded-[2px] border border-border/50 shadow-sm"
|
||||
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
ref={callsignRef}
|
||||
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
|
||||
value={callsign}
|
||||
onChange={(e) => onCallsignInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-24">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label>
|
||||
<Select value={band} onValueChange={onBandUserChange}>
|
||||
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">Mode <LockBtn k="mode" title="mode" /></Label>
|
||||
<Select value={mode} onValueChange={onModeUserChange}>
|
||||
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">
|
||||
{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} <LockBtn k="freq" title="frequency" />
|
||||
</Label>
|
||||
<Input
|
||||
tabIndex={-1}
|
||||
className="font-mono"
|
||||
value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
|
||||
placeholder="14.250"
|
||||
onFocus={() => setFreqFocused(true)}
|
||||
onBlur={() => setFreqFocused(false)}
|
||||
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); }}
|
||||
/>
|
||||
</div>
|
||||
{catState.split && (
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 text-rose-600">RX Freq (MHz)</Label>
|
||||
<Input
|
||||
tabIndex={-1}
|
||||
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
|
||||
placeholder="14.255"
|
||||
onFocus={() => setFreqFocused(true)}
|
||||
onBlur={() => setFreqFocused(false)}
|
||||
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); }}
|
||||
className="font-mono bg-rose-50/40 border-rose-200 focus:bg-card"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST tx</Label>
|
||||
<Input value={rstSent} onChange={(e) => { setRstSent(e.target.value); rstUserEditedRef.current = true; }} />
|
||||
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST rx</Label>
|
||||
<Input value={rstRcvd} onChange={(e) => { setRstRcvd(e.target.value); rstUserEditedRef.current = true; }} />
|
||||
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
<div className="flex flex-col w-24">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-emerald-700">
|
||||
@@ -1405,61 +1446,102 @@ export default function App() {
|
||||
className={cn('font-mono', locks.end ? '' : 'bg-muted/40 cursor-default')}
|
||||
/>
|
||||
</div>
|
||||
{/* Optional ID/location fields — hidden in compact mode. */}
|
||||
|
||||
{/* ── Row 2: Operator name + QTH + Grid + Country + zones (hidden in compact) ── */}
|
||||
{!compact && <>
|
||||
<div className="flex flex-col w-24"><Label className="mb-1 h-3.5">Grid</Label>
|
||||
<Input value={grid} placeholder="JN05" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-[100px]"><Label className="mb-1 h-3.5">Name</Label>
|
||||
<div className="basis-full h-0" aria-hidden />
|
||||
<div className="flex flex-col w-48"><Label className="mb-1 h-3.5">Name</Label>
|
||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-[100px]"><Label className="mb-1 h-3.5">QTH</Label>
|
||||
<div className="flex flex-col w-36"><Label className="mb-1 h-3.5">QTH</Label>
|
||||
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-[100px]"><Label className="mb-1 h-3.5">Country</Label>
|
||||
<Input value={country} onChange={(e) => { setCountry(e.target.value); markEdited('country'); }} />
|
||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">Grid</Label>
|
||||
<Input value={grid} placeholder="JN05" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||
</div>
|
||||
{/* Numeric DXCC metadata + short-path azimuth — surfaced in the
|
||||
main strip per user request (LP + distances stay in F2). */}
|
||||
<div className="flex flex-col w-16"><Label className="mb-1 h-3.5">DXCC #</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="font-mono"
|
||||
value={details.dxcc ?? ''}
|
||||
onChange={(e) => updateDetails({ dxcc: e.target.value === '' ? undefined : parseInt(e.target.value, 10) || undefined })}
|
||||
placeholder="—"
|
||||
/>
|
||||
<div className="flex flex-col w-40">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1.5">
|
||||
Country
|
||||
{flagURL(details.dxcc) && (
|
||||
<img src={flagURL(details.dxcc)} alt="" className="h-3 rounded-[2px] border border-border/50 shadow-sm"
|
||||
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
</Label>
|
||||
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
|
||||
</div>
|
||||
<div className="flex flex-col w-16"><Label className="mb-1 h-3.5">CQ</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="font-mono"
|
||||
value={details.cqz ?? ''}
|
||||
onChange={(e) => updateDetails({ cqz: e.target.value === '' ? undefined : parseInt(e.target.value, 10) || undefined })}
|
||||
placeholder="—"
|
||||
/>
|
||||
{/* DXCC # and Continent are derived from the callsign — read-only.
|
||||
CQ/ITU stay editable but as plain text (no number spinners).
|
||||
Kept compact (Log4OM-style) — just wide enough for their digits. */}
|
||||
<div className="flex flex-col w-11"><Label className="mb-1 h-3.5">DXCC</Label>
|
||||
<Input readOnly tabIndex={-1} className="font-mono bg-muted/40 cursor-default text-center px-1 text-xs"
|
||||
value={details.dxcc ?? ''} placeholder="—" />
|
||||
</div>
|
||||
<div className="flex flex-col w-16"><Label className="mb-1 h-3.5">ITU</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="font-mono"
|
||||
value={details.ituz ?? ''}
|
||||
onChange={(e) => updateDetails({ ituz: e.target.value === '' ? undefined : parseInt(e.target.value, 10) || undefined })}
|
||||
placeholder="—"
|
||||
/>
|
||||
<div className="flex flex-col w-9"><Label className="mb-1 h-3.5">CQ</Label>
|
||||
<Input inputMode="numeric" maxLength={2} className="font-mono text-center px-1 text-xs" value={details.cqz ?? ''} placeholder="—"
|
||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||
</div>
|
||||
<div className="flex flex-col w-14"><Label className="mb-1 h-3.5">Cont</Label>
|
||||
<Input
|
||||
className="font-mono uppercase"
|
||||
value={details.cont}
|
||||
onChange={(e) => updateDetails({ cont: e.target.value.toUpperCase() })}
|
||||
placeholder="—"
|
||||
maxLength={2}
|
||||
/>
|
||||
<div className="flex flex-col w-9"><Label className="mb-1 h-3.5">ITU</Label>
|
||||
<Input inputMode="numeric" maxLength={2} className="font-mono text-center px-1 text-xs" value={details.ituz ?? ''} placeholder="—"
|
||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||
</div>
|
||||
<div className="flex flex-col w-9"><Label className="mb-1 h-3.5">Cont</Label>
|
||||
<Input readOnly tabIndex={-1} className="font-mono uppercase bg-muted/40 cursor-default text-center px-1 text-xs"
|
||||
value={details.cont} placeholder="—" />
|
||||
</div>
|
||||
</>}
|
||||
{/* Comment stays visible in compact mode — handy for quick contest/
|
||||
portable annotations alongside the basic frequency info. */}
|
||||
|
||||
{/* ── Row 3: Freq + Band + Mode + Band RX + RX Freq ── */}
|
||||
<div className="basis-full h-0" aria-hidden />
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">
|
||||
{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} <LockBtn k="freq" title="frequency" />
|
||||
</Label>
|
||||
<Input
|
||||
tabIndex={-1}
|
||||
className="font-mono"
|
||||
value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
|
||||
placeholder="14.250"
|
||||
onFocus={() => setFreqFocused(true)}
|
||||
onBlur={() => setFreqFocused(false)}
|
||||
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-24">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label>
|
||||
<Select value={band} onValueChange={onBandUserChange}>
|
||||
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">Mode <LockBtn k="mode" title="mode" /></Label>
|
||||
<Select value={mode} onValueChange={onModeUserChange}>
|
||||
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col w-24">
|
||||
<Label className="mb-1 h-3.5">Band RX</Label>
|
||||
<Select value={bandRx} onValueChange={setBandRx}>
|
||||
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col w-28">
|
||||
<Label className={cn('mb-1 h-3.5', catState.split && 'text-rose-600')}>RX Freq (MHz)</Label>
|
||||
<Input
|
||||
tabIndex={-1}
|
||||
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
|
||||
placeholder="14.255"
|
||||
onFocus={() => setFreqFocused(true)}
|
||||
onBlur={() => setFreqFocused(false)}
|
||||
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
|
||||
className={cn('font-mono', catState.split && 'bg-rose-50/40 border-rose-200 focus:bg-card')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Row 4: Comment + Note ── */}
|
||||
<div className="basis-full h-0" aria-hidden />
|
||||
<div className="flex flex-col flex-1 min-w-[120px]"><Label className="mb-1 h-3.5">Comment</Label>
|
||||
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
</div>
|
||||
@@ -1468,60 +1550,74 @@ export default function App() {
|
||||
<Input value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col ml-auto">
|
||||
<Label className="mb-1 h-3.5"> </Label>
|
||||
<Button onClick={save} disabled={saving} className="h-8">
|
||||
<Send className="size-3.5" />
|
||||
{saving ? '…' : 'Log QSO'}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{/* Send DX spot — only when a cluster is connected. Pre-fills the
|
||||
dialog from the current entry (or the last logged QSO). */}
|
||||
{clusterServerStatuses.some((s) => s.state === 'connected') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowSpotModal(true)}
|
||||
className="h-8"
|
||||
title="Send a DX spot to the master cluster"
|
||||
>
|
||||
<Satellite className="size-3.5" />
|
||||
Spot
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={save} disabled={saving} className="h-8">
|
||||
<Send className="size-3.5" />
|
||||
{saving ? '…' : 'Log QSO'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* In compact mode the entry strip is the whole app — hide everything
|
||||
else and let the user re-expand with the topbar toggle. */}
|
||||
{compact ? null : <>
|
||||
{/* ===== BAND/SLOT GRID ===== */}
|
||||
{/* QRZ profile picture sits next to the matrix when the user has
|
||||
opted in (Settings → Lookup → Show QRZ profile pictures). The
|
||||
backend returns image_url="" when the toggle is off, so we
|
||||
don't need to re-check the setting here. */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<BandSlotGrid wb={wb} busy={wbBusy} currentBand={band} currentMode={mode} />
|
||||
{/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail
|
||||
tabs, then reserved free space. Hidden in compact mode. */}
|
||||
{!compact && (
|
||||
<div className="w-[560px] shrink-0 min-h-0 flex flex-col">
|
||||
<DetailsPanel
|
||||
callsign={callsign}
|
||||
prefix={prefix}
|
||||
operatorGrid={station.my_grid}
|
||||
remoteGrid={grid}
|
||||
details={details}
|
||||
onChange={updateDetails}
|
||||
wb={wb}
|
||||
wbBusy={wbBusy}
|
||||
band={band}
|
||||
mode={mode}
|
||||
/>
|
||||
</div>
|
||||
{lookupResult?.image_url && (
|
||||
<a
|
||||
href={lookupResult.image_url}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
OpenExternalURL(lookupResult.image_url!).catch((err) => setError(String(err?.message ?? err)));
|
||||
}}
|
||||
)}
|
||||
{/* Reserved free space to the right — shows the QRZ profile photo large
|
||||
so it's actually legible. Click opens the full-size image on QRZ. */}
|
||||
{!compact && lookupResult?.image_url && (
|
||||
<div className="flex-1 min-w-0 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lookupResult.image_url && OpenExternalURL(lookupResult.image_url).catch((err) => setError(String(err?.message ?? err)))}
|
||||
className="rounded-lg border border-border overflow-hidden hover:border-primary/60 transition-colors bg-muted/20"
|
||||
title="Open full-size on QRZ.com"
|
||||
className="block shrink-0 rounded border border-border overflow-hidden hover:border-primary/60 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={lookupResult.image_url}
|
||||
alt={`${callsign} profile`}
|
||||
className="block w-[160px] h-[120px] object-cover bg-muted/30"
|
||||
alt="profile"
|
||||
className="block max-h-[180px] max-w-full w-auto object-contain"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== F2-F5 DETAILS ===== */}
|
||||
<DetailsPanel
|
||||
callsign={callsign}
|
||||
prefix={prefix}
|
||||
operatorGrid={station.my_grid}
|
||||
remoteGrid={grid}
|
||||
details={details}
|
||||
onChange={updateDetails}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>{/* /entry + aside row */}
|
||||
|
||||
{/* ===== LOWER: tabs+table | call history | (optional) band map ===== */}
|
||||
{compact ? null : <>
|
||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]', showBandMap ? 'grid-cols-[1fr_260px]' : 'grid-cols-[1fr]')}>
|
||||
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
|
||||
@@ -1540,6 +1636,21 @@ export default function App() {
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="awards">Awards</TabsTrigger>
|
||||
<TabsTrigger value="propagation">Propagation</TabsTrigger>
|
||||
{qslTabOpen && (
|
||||
<TabsTrigger value="qsl" className="gap-1.5">
|
||||
QSL Manager
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Close QSL Manager"
|
||||
title="Close"
|
||||
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => { e.stopPropagation(); closeQslTab(); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recent" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
@@ -1949,6 +2060,15 @@ export default function App() {
|
||||
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||
tab's × . forceMount keeps its state (and a running download
|
||||
updating) while you work on other tabs. */}
|
||||
{qslTabOpen && (
|
||||
<TabsContent value="qsl" forceMount className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||
<QSLManagerPanel />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{(['main','awards','propagation'] as const).map((t) => (
|
||||
<TabsContent key={t} value={t} className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
|
||||
<Hash className="size-10 opacity-30" />
|
||||
@@ -1985,9 +2105,87 @@ export default function App() {
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{/* ===== STATUS BAR ===== */}
|
||||
{!compact && (() => {
|
||||
const clusterUp = clusterServerStatuses.some((s) => s.state === 'connected');
|
||||
const catUp = catState.enabled && catState.connected;
|
||||
const Chip = ({ on, label, title, onClick, disabled }: { on: boolean; label: React.ReactNode; title?: string; onClick?: () => void; disabled?: boolean }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors',
|
||||
disabled ? 'opacity-50 cursor-default border-transparent'
|
||||
: 'border-border hover:bg-muted cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-2 rounded-full', on ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<footer className="flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
|
||||
</span>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<Chip on={clusterUp} label="Cluster" title={clusterUp ? 'Cluster connected' : 'Cluster offline'} onClick={() => setActiveTab('cluster')} />
|
||||
<Chip
|
||||
on={catUp}
|
||||
label={<span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{catUp ? (catState.rig || 'CAT') : (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT')}</span>}
|
||||
title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')}
|
||||
onClick={() => { setSettingsSection('cat'); setShowSettings(true); }}
|
||||
/>
|
||||
<Chip
|
||||
on={rotatorHeading.enabled && rotatorHeading.ok}
|
||||
label={rotatorHeading.enabled && rotatorHeading.ok ? `Rotator ${rotatorHeading.azimuth}°` : 'Rotator'}
|
||||
title={rotatorHeading.enabled ? (rotatorHeading.ok ? `Antenna heading ${rotatorHeading.azimuth}°` : 'Rotator: no position') : 'Rotator disabled'}
|
||||
disabled={!rotatorHeading.enabled}
|
||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
</footer>
|
||||
);
|
||||
})()}
|
||||
|
||||
{editingQSO && (
|
||||
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} />
|
||||
)}
|
||||
|
||||
<SendSpotModal
|
||||
open={showSpotModal}
|
||||
onClose={() => setShowSpotModal(false)}
|
||||
// Callsign: the QSO-entry call, else the last logged QSO.
|
||||
defaultCall={callsign.trim() || qsos[0]?.callsign || ''}
|
||||
// Freq: the entry TX freq (kHz), else the last logged QSO's.
|
||||
defaultFreqKHz={
|
||||
parseFloat(freqMhz) > 0
|
||||
? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
|
||||
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
|
||||
}
|
||||
defaultMode={mode || qsos[0]?.mode || ''}
|
||||
targetName={
|
||||
clusterServers
|
||||
.filter((s) => s.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)[0]?.name
|
||||
}
|
||||
recent={qsos.slice(0, 8).map((q): RecentSpotQSO => ({
|
||||
callsign: q.callsign,
|
||||
freqKHz: q.freq_hz ? Math.round((q.freq_hz / 1000) * 10) / 10 : 0,
|
||||
mode: q.mode ?? '',
|
||||
band: q.band,
|
||||
}))}
|
||||
onSend={async (call, freqKHz, comment) => {
|
||||
await SendClusterSpot(call, freqKHz, comment);
|
||||
const target = clusterServers
|
||||
.filter((s) => s.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)[0]?.name;
|
||||
showToast(`Spot ${call} sent${target ? ` on ${target}` : ''}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
{showSettings && (
|
||||
<SettingsModal
|
||||
initialSection={settingsSection}
|
||||
@@ -1995,7 +2193,6 @@ export default function App() {
|
||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); }}
|
||||
/>
|
||||
)}
|
||||
<QSLManagerModal open={showQSLManager} onClose={() => setShowQSLManager(false)} />
|
||||
|
||||
{deletingQSO && (
|
||||
<ConfirmDialog
|
||||
|
||||
Reference in New Issue
Block a user