feat: status bar added

This commit is contained in:
2026-05-30 01:35:50 +02:00
parent 8f1ad126ac
commit 806b39970b
24 changed files with 1933 additions and 451 deletions
+389 -192
View File
@@ -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">&nbsp;</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