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
|
||||
|
||||
@@ -65,6 +65,28 @@ const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
|
||||
};
|
||||
|
||||
// Small coloured dot + label used in the band-map legend strip.
|
||||
function LegendDot({ cls, label }: { cls: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={cn('size-2 rounded-full', cls)} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Human-readable label for a spot status — used in the pill hover tooltip
|
||||
// so the operator can see WHY a spot is coloured the way it is.
|
||||
function statusLabel(s: string): string {
|
||||
switch (s) {
|
||||
case 'new': return 'NEW DXCC (entity never worked)';
|
||||
case 'new-band': return 'NEW BAND (entity not worked on this band)';
|
||||
case 'new-slot': return 'NEW SLOT (mode not worked on this band)';
|
||||
case 'worked': return 'Worked (this band + mode already in log)';
|
||||
default: return 'Entity not resolved';
|
||||
}
|
||||
}
|
||||
|
||||
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
|
||||
// pill = full pill background+text+border
|
||||
// bar = thick left accent inside the pill
|
||||
@@ -114,6 +136,13 @@ const PILL_GAP = 32; // px between scale border and first pill (room for leade
|
||||
const LABEL_W = 200;
|
||||
const TOP_PAD = 14; // px of breathing room above/below the band edges so
|
||||
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||
// Max FT-mode (FT8/FT4/…) pills drawn at once. These pile up on a single
|
||||
// watering-hole frequency and otherwise spawn hundreds of spots that fan out
|
||||
// and cover the whole map. ONLY the FT family is capped — CW, SSB and other
|
||||
// digital spots are always shown in full. When more than this FT spots are in
|
||||
// band we keep the most useful (new entities first, worked last; ties broken
|
||||
// by closeness to the rig freq).
|
||||
const MAX_VISIBLE_SPOTS = 30;
|
||||
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
||||
const range = BAND_RANGES[band];
|
||||
@@ -146,19 +175,53 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
// larger). When more labels stack than fit in the band's natural pixel
|
||||
// span, totalH grows so scrolling reveals them.
|
||||
type Placed = { spot: Spot; freqY: number; labelY: number };
|
||||
const { placed, totalH } = useMemo<{ placed: Placed[]; totalH: number }>(() => {
|
||||
const { placed, totalH, hidden } = useMemo<{ placed: Placed[]; totalH: number; hidden: number }>(() => {
|
||||
// innerH is the band's stretched pixel span; total adds top+bottom
|
||||
// padding so the edge freq labels aren't clipped at y=0 / y=H.
|
||||
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
||||
if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD };
|
||||
if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD, hidden: 0 };
|
||||
const seen = new Set<string>();
|
||||
const filtered: Spot[] = [];
|
||||
const inBand: Spot[] = [];
|
||||
for (const s of spots) {
|
||||
if (s.freq_khz < lo || s.freq_khz > hi) continue;
|
||||
if (seen.has(s.dx_call)) continue;
|
||||
seen.add(s.dx_call);
|
||||
filtered.push(s);
|
||||
inBand.push(s);
|
||||
}
|
||||
|
||||
// Only the FT family (FT8/FT4/…) is capped — it's what floods a single
|
||||
// watering-hole frequency. Everything else (CW, SSB, RTTY, PSK, …) is
|
||||
// always shown in full.
|
||||
const isFlood = (s: Spot) => /^FT/.test(inferSpotMode(s.comment ?? '', s.freq_hz));
|
||||
const ftSpots = inBand.filter(isFlood);
|
||||
const otherSpots = inBand.filter((s) => !isFlood(s));
|
||||
|
||||
// Rank an FT spot by usefulness (new entity → unworked → worked); ties
|
||||
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
|
||||
const rank = (s: Spot) => {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
switch (spotStatus[k]?.status ?? '') {
|
||||
case 'new': return 0;
|
||||
case 'new-band': return 1;
|
||||
case 'new-slot': return 2;
|
||||
case 'worked': return 4;
|
||||
default: return 3;
|
||||
}
|
||||
};
|
||||
let keptFt = ftSpots;
|
||||
let hiddenCount = 0;
|
||||
if (ftSpots.length > MAX_VISIBLE_SPOTS) {
|
||||
const rigK = currentFreqHz ? currentFreqHz / 1000 : (lo + hi) / 2;
|
||||
keptFt = [...ftSpots]
|
||||
.sort((a, b) => {
|
||||
const r = rank(a) - rank(b);
|
||||
if (r !== 0) return r;
|
||||
return Math.abs(a.freq_khz - rigK) - Math.abs(b.freq_khz - rigK);
|
||||
})
|
||||
.slice(0, MAX_VISIBLE_SPOTS);
|
||||
hiddenCount = ftSpots.length - keptFt.length;
|
||||
}
|
||||
const filtered = [...otherSpots, ...keptFt];
|
||||
filtered.sort((a, b) => b.freq_khz - a.freq_khz);
|
||||
|
||||
// Desired pill-CENTRE Y for each spot = its true frequency's Y.
|
||||
@@ -206,14 +269,32 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
return {
|
||||
placed: out,
|
||||
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
|
||||
hidden: hiddenCount,
|
||||
};
|
||||
}, [spots, range, lo, hi, span, pxPerKHz, containerH]);
|
||||
}, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz]);
|
||||
|
||||
// freqToY for elements rendered outside the memo (ticks, rig pointer).
|
||||
// Must mirror the same offset so the rig triangle sits on the right kHz.
|
||||
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
||||
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
|
||||
|
||||
// Auto-centre on the rig frequency when the map opens or the band changes
|
||||
// (once per band, so it doesn't fight the user's manual scrolling). Waits
|
||||
// for the scroller height to be measured and a valid in-band rig freq.
|
||||
const centeredForRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
if (!range || containerH <= 0 || currentFreqHz <= 0) return;
|
||||
const kHz = currentFreqHz / 1000;
|
||||
if (kHz < lo || kHz > hi) return;
|
||||
if (centeredForRef.current === band) return;
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
centeredForRef.current = band;
|
||||
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
||||
// freqToY is recomputed each render; intentionally excluded from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [band, containerH, currentFreqHz, range, lo, hi]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
@@ -375,7 +456,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
{/* Pills absolutely positioned at their (anti-overlapped) Y */}
|
||||
{placed.map((p, i) => {
|
||||
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
|
||||
const st = spotStatus[k]?.status ?? '';
|
||||
const entry = spotStatus[k];
|
||||
const st = entry?.status ?? '';
|
||||
const style = statusStyle(st);
|
||||
const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
|
||||
return (
|
||||
@@ -389,7 +471,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
'hover:translate-x-0.5 hover:shadow',
|
||||
style.pill,
|
||||
)}
|
||||
title={`${p.spot.dx_call} · ${p.spot.freq_khz.toFixed(1)} kHz${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
>
|
||||
{/* Status accent strip on the left */}
|
||||
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
|
||||
@@ -406,8 +488,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Colour legend — what each pill colour means. */}
|
||||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||||
<LegendDot cls="bg-rose-400" label="New DXCC" />
|
||||
<LegendDot cls="bg-amber-400" label="New band" />
|
||||
<LegendDot cls="bg-yellow-300" label="New slot (mode)" />
|
||||
<LegendDot cls="bg-muted-foreground/30" label="Worked" />
|
||||
</div>
|
||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
||||
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronUp, Construction } from 'lucide-react';
|
||||
import { Construction } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { pathBetween } from '@/lib/maidenhead';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
|
||||
export interface DetailsState {
|
||||
state: string;
|
||||
@@ -45,9 +46,16 @@ interface Props {
|
||||
remoteGrid: string; // entry-strip Grid value — destination
|
||||
details: DetailsState;
|
||||
onChange: (patch: Partial<DetailsState>) => void;
|
||||
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
||||
wb?: any;
|
||||
wbBusy?: boolean;
|
||||
band: string;
|
||||
mode: string;
|
||||
imageUrl?: string;
|
||||
onOpenImage?: () => void;
|
||||
}
|
||||
|
||||
type TabName = 'info' | 'awards' | 'my' | 'extended';
|
||||
type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||
|
||||
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||
|
||||
@@ -67,8 +75,8 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange }: Props) {
|
||||
const [open, setOpen] = useState<TabName | null>(null);
|
||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode }: Props) {
|
||||
const [open, setOpen] = useState<TabName>('stats');
|
||||
|
||||
// Bearing/distance from operator's home grid to the remote station.
|
||||
// Recomputed only when either grid actually changes.
|
||||
@@ -79,7 +87,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||
|
||||
function toggle(t: TabName) { setOpen((prev) => (prev === t ? null : t)); }
|
||||
function toggle(t: TabName) { setOpen(t); }
|
||||
|
||||
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
|
||||
function setSatellite(on: boolean) {
|
||||
@@ -94,6 +102,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
}
|
||||
|
||||
const tabs: { key: TabName; label: string }[] = [
|
||||
{ key: 'stats', label: 'Stats (F1)' },
|
||||
{ key: 'info', label: 'Info (F2)' },
|
||||
{ key: 'awards', label: 'Awards (F3)' },
|
||||
{ key: 'my', label: 'My (F4)' },
|
||||
@@ -101,8 +110,8 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="border-b border-border bg-card shrink-0">
|
||||
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border">
|
||||
<section className="border border-border rounded-lg bg-card flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
@@ -117,17 +126,15 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
{open && (
|
||||
<button
|
||||
onClick={() => setOpen(null)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground p-1.5"
|
||||
title="Close"
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="overflow-y-auto min-h-0">
|
||||
{open === 'stats' && (
|
||||
<div className="px-3 py-2.5">
|
||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open === 'info' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="State / pref">
|
||||
@@ -251,6 +258,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { UploadCloud, DownloadCloud, Search, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
@@ -28,7 +25,6 @@ const SERVICES = [
|
||||
{ v: 'lotw', label: 'LoTW' },
|
||||
];
|
||||
|
||||
// Sent-status filter values. Empty string = blank/none.
|
||||
const SENT_STATUSES = [
|
||||
{ v: 'R', label: 'Requested' },
|
||||
{ v: 'N', label: 'No' },
|
||||
@@ -46,7 +42,9 @@ function fmtDate(iso: string): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
||||
// and download confirmations, while the rest of the app stays usable.
|
||||
export function QSLManagerPanel() {
|
||||
const [service, setService] = useState('lotw');
|
||||
const [sent, setSent] = useState('R');
|
||||
const [rows, setRows] = useState<UploadRow[]>([]);
|
||||
@@ -55,22 +53,20 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
const [error, setError] = useState('');
|
||||
const [addNotFound, setAddNotFound] = useState(false);
|
||||
|
||||
// 'upload' shows the Select-required search results; 'confirmations' shows
|
||||
// the rows returned by a Download.
|
||||
const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
|
||||
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
|
||||
const [confFilter, setConfFilter] = useState('all'); // all | new | dxcc | band | slot
|
||||
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
const [logTitle, setLogTitle] = useState('');
|
||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||
const [showLog, setShowLog] = useState(false);
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
const [uploadDone, setUploadDone] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||
|
||||
useEffect(() => {
|
||||
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
||||
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
||||
setUploadDone(true);
|
||||
setBusy(false);
|
||||
});
|
||||
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
||||
setConfirmations((list ?? []) as Confirmation[]);
|
||||
@@ -81,16 +77,28 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
|
||||
const selectedCount = selected.size;
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||
|
||||
async function selectRequired() {
|
||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||
switch (confFilter) {
|
||||
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
||||
case 'dxcc': return c.new_dxcc;
|
||||
case 'band': return c.new_band;
|
||||
case 'slot': return c.new_slot;
|
||||
default: return true;
|
||||
}
|
||||
}), [confirmations, confFilter]);
|
||||
|
||||
const selectRequired = useCallback(async () => {
|
||||
setSearching(true);
|
||||
setError('');
|
||||
try {
|
||||
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
||||
const list = (r ?? []) as UploadRow[];
|
||||
setRows(list);
|
||||
setSelected(new Set(list.map((x) => x.id))); // auto-select all found
|
||||
setSelected(new Set(list.map((x) => x.id)));
|
||||
setViewMode('upload');
|
||||
setShowLog(false);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
setRows([]);
|
||||
@@ -98,7 +106,7 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
}, [service, sent]);
|
||||
|
||||
function toggle(id: number) {
|
||||
setSelected((s) => {
|
||||
@@ -114,209 +122,170 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
async function upload() {
|
||||
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
|
||||
if (ids.length === 0) return;
|
||||
setLogLines([]);
|
||||
setUploadDone(false);
|
||||
setLogAction('upload');
|
||||
setLogTitle('Uploading to ' + serviceLabel);
|
||||
setLogOpen(true);
|
||||
try {
|
||||
await UploadQSOsManual(service, ids);
|
||||
} catch (e: any) {
|
||||
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
|
||||
setUploadDone(true);
|
||||
}
|
||||
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||
try { await UploadQSOsManual(service, ids); }
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
async function download() {
|
||||
setLogLines([]);
|
||||
setUploadDone(false);
|
||||
setLogAction('download');
|
||||
setLogTitle('Downloading confirmations from ' + serviceLabel);
|
||||
setLogOpen(true);
|
||||
try {
|
||||
await DownloadConfirmations(service, addNotFound);
|
||||
} catch (e: any) {
|
||||
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
|
||||
setUploadDone(true);
|
||||
}
|
||||
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
|
||||
try { await DownloadConfirmations(service, addNotFound); }
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
function closeLog() {
|
||||
setLogOpen(false);
|
||||
// After an upload, refresh the search so uploaded QSOs drop out of the
|
||||
// filter. After a download, leave the confirmations list on screen.
|
||||
function viewResults() {
|
||||
setShowLog(false);
|
||||
if (logAction === 'upload') selectRequired();
|
||||
}
|
||||
|
||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-[1000px] w-full max-h-[88vh] grid grid-rows-[auto_auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-4 pt-4">
|
||||
<DialogTitle>QSL Manager</DialogTitle>
|
||||
<DialogDescription className="sr-only">Upload logged QSOs to online logbooks.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Search toolbar */}
|
||||
<div className="flex items-end gap-3 px-4 py-3 border-b border-border bg-muted/20">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
||||
<Select value={service} onValueChange={setService}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
||||
<Select value={sent} onValueChange={setSent}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching}>
|
||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
Select required
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{viewMode === 'confirmations'
|
||||
? `${confirmations.length} confirmation(s) received`
|
||||
: `${rows.length} found · ${selectedCount} selected`}
|
||||
</span>
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
{/* Search toolbar */}
|
||||
<div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
||||
<Select value={service} onValueChange={setService}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
||||
<Select value={sent} onValueChange={setSent}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
|
||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
Select required
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
{!showLog && viewMode === 'confirmations' && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
|
||||
<Select value={confFilter} onValueChange={setConfFilter}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="new">New (any)</SelectItem>
|
||||
<SelectItem value="dxcc">New DXCC</SelectItem>
|
||||
<SelectItem value="band">New band</SelectItem>
|
||||
<SelectItem value="slot">New slot</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{logLines.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
|
||||
{showLog ? <><ListChecks className="size-3.5" /> Results</> : <><ScrollText className="size-3.5" /> Log</>}
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{viewMode === 'confirmations'
|
||||
? `${shownConfs.length} / ${confirmations.length} confirmation(s)`
|
||||
: `${rows.length} found · ${selectedCount} selected`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results grid */}
|
||||
<div className="overflow-auto px-4 py-2 min-h-[200px]">
|
||||
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
||||
{/* Content: log OR results grid */}
|
||||
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
||||
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
||||
|
||||
{viewMode === 'confirmations' ? (
|
||||
confirmations.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">No new confirmations.</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2">Date UTC</th>
|
||||
<th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th>
|
||||
<th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">Country</th>
|
||||
<th className="py-1.5 px-2">New?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{confirmations.map((c, i) => (
|
||||
<tr key={i} className="border-b border-border/40">
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
||||
<td className="py-1 px-2">{c.band}</td>
|
||||
<td className="py-1 px-2">{c.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||
<td className="py-1 px-2">
|
||||
{c.new_dxcc ? (
|
||||
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
|
||||
) : c.new_band ? (
|
||||
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
|
||||
) : c.new_slot ? (
|
||||
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||
Pick a service + sent status, then “Select required”.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||
<th className="py-1.5 px-2">Date UTC</th>
|
||||
<th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th>
|
||||
<th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">Country</th>
|
||||
<th className="py-1.5 px-2">Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
|
||||
onClick={() => toggle(r.id)}
|
||||
>
|
||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
|
||||
</td>
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
||||
<td className="py-1 px-2">{r.band}</td>
|
||||
<td className="py-1 px-2">{r.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
|
||||
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-3 border-t border-border sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={download} title="Fetch confirmations from the service and update received status">
|
||||
<DownloadCloud className="size-3.5" />
|
||||
Download confirmations
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
|
||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||
Add not-found
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0}>
|
||||
<UploadCloud className="size-3.5" />
|
||||
Upload {selectedCount} to {serviceLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Upload progress / log window */}
|
||||
<Dialog open={logOpen} onOpenChange={(o) => { if (!o && uploadDone) closeLog(); }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{logTitle || 'Working…'}</DialogTitle>
|
||||
<DialogDescription className="sr-only">Progress log.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border border-border bg-muted/30 p-2.5 font-mono text-[11px] space-y-0.5">
|
||||
{showLog ? (
|
||||
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
||||
{logLines.length === 0 ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting…</div>
|
||||
) : logLines.map((l, i) => (
|
||||
<div key={i} className={cn(l.includes('FAILED') || l.includes('failed') ? 'text-rose-700' : l.includes('OK') ? 'text-emerald-700' : 'text-foreground')}>{l}</div>
|
||||
<div key={i} className={cn(
|
||||
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
|
||||
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
|
||||
: 'text-foreground/90')}>{l}</div>
|
||||
))}
|
||||
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> working…</div>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button size="sm" onClick={closeLog} disabled={!uploadDone}>
|
||||
{uploadDone ? 'Close' : <><Loader2 className="size-3.5 animate-spin" /> Uploading…</>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
) : viewMode === 'confirmations' ? (
|
||||
shownConfs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||
{confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">New?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shownConfs.map((c, i) => (
|
||||
<tr key={i} className="border-b border-border/40">
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
||||
<td className="py-1 px-2">{c.band}</td>
|
||||
<td className="py-1 px-2">{c.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||
<td className="py-1 px-2">
|
||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
|
||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
|
||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then “Select required”.</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}
|
||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
|
||||
onClick={() => toggle(r.id)}>
|
||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
|
||||
</td>
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
||||
<td className="py-1 px-2">{r.band}</td>
|
||||
<td className="py-1 px-2">{r.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
|
||||
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||
title="Fetch confirmations from the service and update received status">
|
||||
<DownloadCloud className="size-3.5" /> Download confirmations
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
|
||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||
Add not-found
|
||||
</label>
|
||||
</div>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||
import { LookupCallsign } from '../../wailsjs/go/main/App';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -104,11 +105,47 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
|
||||
const [localErr, setLocalErr] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [looking, setLooking] = useState(false);
|
||||
|
||||
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
||||
setDraft((d) => ({ ...d, [key]: value }));
|
||||
}
|
||||
|
||||
// Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into
|
||||
// the draft — handy after correcting the callsign. Only overwrites the
|
||||
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
||||
async function fetchLookup() {
|
||||
const call = (draft.callsign ?? '').trim().toUpperCase();
|
||||
if (!call) { setLocalErr('Callsign required'); return; }
|
||||
setLooking(true);
|
||||
setLocalErr('');
|
||||
try {
|
||||
const r: any = await LookupCallsign(call);
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
name: r.name ?? d.name,
|
||||
qth: r.qth ?? d.qth,
|
||||
address: r.address ?? (d as any).address,
|
||||
email: r.email ?? (d as any).email,
|
||||
country: r.country ?? d.country,
|
||||
grid: r.grid ?? d.grid,
|
||||
state: r.state ?? d.state,
|
||||
cnty: r.cnty ?? d.cnty,
|
||||
cont: r.cont ?? d.cont,
|
||||
qsl_via: r.qsl_via ?? d.qsl_via,
|
||||
dxcc: r.dxcc || d.dxcc,
|
||||
cqz: r.cqz || d.cqz,
|
||||
ituz: r.ituz || d.ituz,
|
||||
lat: r.lat || d.lat,
|
||||
lon: r.lon || d.lon,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
setLocalErr('Lookup: ' + String(e?.message ?? e));
|
||||
} finally {
|
||||
setLooking(false);
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
|
||||
setSaving(true);
|
||||
@@ -200,8 +237,15 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
<TabsContent value="basic" className="mt-0">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<F label="Callsign" span={6}>
|
||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11"
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11 flex-1"
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
<Button type="button" variant="outline" className="h-11" onClick={fetchLookup} disabled={looking}
|
||||
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, QTH, country, grid, zones…">
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />}
|
||||
Fetch
|
||||
</Button>
|
||||
</div>
|
||||
</F>
|
||||
<F label="Start (UTC)" span={3}><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></F>
|
||||
<F label="End (UTC)" span={3}><Input type="datetime-local" value={dateOff} onChange={(e) => setDateOff(e.target.value)} /></F>
|
||||
@@ -232,6 +276,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
<F label="RST sent"><Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} /></F>
|
||||
<F label="RST rcvd"><Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} /></F>
|
||||
<F label="TX power (W)"><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
|
||||
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
|
||||
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ const COL_CATALOG: ColEntry[] = [
|
||||
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'QRZ.com upload date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com date', field: 'qrzcom_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com upload status', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com status', field: 'qrzcom_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'QRZ.com confirmed', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com cfm', field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
|
||||
// ── Contest ──
|
||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Satellite, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface RecentSpotQSO {
|
||||
callsign: string;
|
||||
freqKHz: number;
|
||||
mode: string;
|
||||
band?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
// Pre-fill values: callsign from the QSO entry (or last logged), the
|
||||
// current TX freq in kHz, and the current mode (goes into the comment).
|
||||
defaultCall: string;
|
||||
defaultFreqKHz: number;
|
||||
defaultMode: string;
|
||||
// Master cluster name, shown so the user knows where the spot goes.
|
||||
targetName?: string;
|
||||
recent: RecentSpotQSO[];
|
||||
onSend: (call: string, freqKHz: number, comment: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// SendSpotModal — Log4OM-style "Send Spot" window. Announces a DX spot on
|
||||
// the master cluster: callsign + frequency (kHz) + a free message (defaults
|
||||
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
||||
// contact into the form.
|
||||
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
|
||||
const [call, setCall] = useState('');
|
||||
const [freqKHz, setFreqKHz] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [ok, setOk] = useState(false);
|
||||
const callRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// (Re)initialise the form each time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCall((defaultCall || '').toUpperCase());
|
||||
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
|
||||
setMessage(defaultMode || '');
|
||||
setError('');
|
||||
setOk(false);
|
||||
// Focus the freq if the call is already known, else the call.
|
||||
setTimeout(() => callRef.current?.focus(), 50);
|
||||
}, [open, defaultCall, defaultFreqKHz, defaultMode]);
|
||||
|
||||
async function send() {
|
||||
const c = call.trim().toUpperCase();
|
||||
const f = parseFloat(freqKHz);
|
||||
if (!c) { setError('Callsign required'); return; }
|
||||
if (!f || f <= 0) { setError('Frequency (kHz) required'); return; }
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await onSend(c, f, message.trim());
|
||||
setOk(true);
|
||||
// Brief success flash, then close.
|
||||
setTimeout(() => { setOk(false); onClose(); }, 700);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pick(q: RecentSpotQSO) {
|
||||
setCall(q.callsign.toUpperCase());
|
||||
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
|
||||
if (q.mode) setMessage(q.mode);
|
||||
setError('');
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Satellite className="size-4 text-primary" /> Send DX Spot
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-5 py-3 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col flex-1">
|
||||
<Label className="mb-1">Callsign</Label>
|
||||
<Input
|
||||
ref={callRef}
|
||||
className="font-mono uppercase font-bold"
|
||||
value={call}
|
||||
onChange={(e) => setCall(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="DX call"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-32">
|
||||
<Label className="mb-1">Frequency (kHz)</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={freqKHz}
|
||||
onChange={(e) => setFreqKHz(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="14205"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1">Message</Label>
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||
placeholder="e.g. CW · TNX QSO"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1 block">Latest QSOs</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border/60">
|
||||
{recent.map((q, i) => (
|
||||
<button
|
||||
key={`${q.callsign}-${i}`}
|
||||
type="button"
|
||||
onClick={() => pick(q)}
|
||||
className="flex w-full items-center gap-2 px-2 py-1 text-left text-xs hover:bg-accent/40"
|
||||
>
|
||||
<span className="font-mono font-bold w-24 truncate">{q.callsign}</span>
|
||||
<span className="font-mono text-muted-foreground w-20 text-right">{q.freqKHz > 0 ? trimKHz(q.freqKHz) : '—'}</span>
|
||||
<span className="text-muted-foreground">{q.mode || ''}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-rose-600">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<span className="text-[11px] text-muted-foreground mr-auto self-center">
|
||||
{ok ? 'Spot sent ✓' : targetName ? `→ ${targetName}` : 'Master cluster'}
|
||||
</span>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={send} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Satellite className="size-3.5" />}
|
||||
{busy ? 'Sending…' : 'Send spot'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// trimKHz formats a kHz value without a trailing ".0" (14205) but keeps
|
||||
// sub-kHz precision when present (10138.7).
|
||||
function trimKHz(khz: number): string {
|
||||
return String(Math.round(khz * 10) / 10).replace(/\.0$/, '');
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ConnectClusterServer, DisconnectClusterServer,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
@@ -141,6 +142,7 @@ type SectionId =
|
||||
| 'lists-modes'
|
||||
| 'cluster'
|
||||
| 'backup'
|
||||
| 'database'
|
||||
| 'awards'
|
||||
| 'cat'
|
||||
| 'rotator'
|
||||
@@ -171,6 +173,7 @@ const TREE: TreeNode[] = [
|
||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
|
||||
{ kind: 'item', label: 'Database backup', id: 'backup' },
|
||||
{ kind: 'item', label: 'Database location', id: 'database' },
|
||||
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
||||
],
|
||||
},
|
||||
@@ -196,6 +199,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
'lists-modes': 'Modes & default RST',
|
||||
cluster: 'DX Cluster',
|
||||
backup: 'Database backup',
|
||||
database: 'Database location',
|
||||
udp: 'UDP integrations',
|
||||
awards: 'Awards',
|
||||
cat: 'CAT interface',
|
||||
@@ -319,7 +323,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const [activeProfile, setActiveProfile] = useState<Profile | null>(null);
|
||||
const updateActive = (patch: Partial<Profile>) =>
|
||||
setActiveProfile((p) => (p ? { ...p, ...patch } : p));
|
||||
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [] });
|
||||
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [], rst_phone: [], rst_cw: [], rst_digital: [] });
|
||||
// RST report lists edited as free text (one/space-separated values).
|
||||
const [rstText, setRstText] = useState({ phone: '', cw: '', digital: '' });
|
||||
// Custom band drafts (catalog covers ADIF spec but the user may have
|
||||
// exotic or experimental bands not listed).
|
||||
const [bandDraft, setBandDraft] = useState('');
|
||||
@@ -384,6 +390,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const [backupRunning, setBackupRunning] = useState(false);
|
||||
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
|
||||
const [dbMsg, setDbMsg] = useState('');
|
||||
|
||||
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
||||
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
|
||||
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
|
||||
@@ -457,6 +466,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
setLookup(l);
|
||||
setActiveProfile(ap as Profile);
|
||||
setLists(ls);
|
||||
setRstText({
|
||||
phone: ((ls as any).rst_phone ?? []).join(' '),
|
||||
cw: ((ls as any).rst_cw ?? []).join(' '),
|
||||
digital: ((ls as any).rst_digital ?? []).join(' '),
|
||||
});
|
||||
await reloadProfiles();
|
||||
await reloadClusterServers();
|
||||
setCatCfg(c);
|
||||
@@ -464,6 +478,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
setBackupCfg(b as any);
|
||||
setQslDefaults(qd as any);
|
||||
setExtSvc(es as any);
|
||||
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
||||
try {
|
||||
const locs: any = await ListTQSLStationLocations();
|
||||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||
@@ -600,7 +615,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
default_rst_rcvd: (m.default_rst_rcvd ?? '').trim(),
|
||||
}))
|
||||
.filter((m) => m.name !== '');
|
||||
await SaveListsSettings({ bands, modes } as any);
|
||||
const splitList = (s: string) => s.split(/[\s,]+/).map((x) => x.trim()).filter(Boolean);
|
||||
await SaveListsSettings({
|
||||
bands, modes,
|
||||
rst_phone: splitList(rstText.phone),
|
||||
rst_cw: splitList(rstText.cw),
|
||||
rst_digital: splitList(rstText.digital),
|
||||
} as any);
|
||||
|
||||
if (activeProfile) {
|
||||
await SaveProfile({
|
||||
@@ -1233,6 +1254,28 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RST report lists — the dropdown choices in the entry form. */}
|
||||
<div className="mt-6 max-w-4xl">
|
||||
<div className="text-sm font-semibold mb-1">RST report lists</div>
|
||||
<div className="text-[11px] text-muted-foreground mb-2">
|
||||
The choices offered in the entry form's RST dropdowns, per mode family. One value per line (or space-separated). The first one is the top of the list.
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Phone (SSB/AM/FM)</Label>
|
||||
<Textarea rows={8} className="font-mono text-xs" value={rstText.phone} onChange={(e) => setRstText((s) => ({ ...s, phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">CW / RTTY / PSK</Label>
|
||||
<Textarea rows={8} className="font-mono text-xs" value={rstText.cw} onChange={(e) => setRstText((s) => ({ ...s, cw: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Digital (FT8/FT4/JT…) — dB</Label>
|
||||
<Textarea rows={8} className="font-mono text-xs" value={rstText.digital} onChange={(e) => setRstText((s) => ({ ...s, digital: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2121,6 +2164,74 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function DatabasePanel() {
|
||||
async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} }
|
||||
async function openExisting() {
|
||||
try {
|
||||
const p = await PickOpenDatabase();
|
||||
if (!p) return;
|
||||
await OpenDatabase(p);
|
||||
await refreshDb();
|
||||
setDbMsg(`Database set to:\n${p}\nRestart OpsLog to apply.`);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function saveCopy() {
|
||||
try {
|
||||
const p = await PickSaveDatabase();
|
||||
if (!p) return;
|
||||
await MoveDatabase(p);
|
||||
await refreshDb();
|
||||
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function resetDefault() {
|
||||
try {
|
||||
await ResetDatabaseToDefault();
|
||||
await refreshDb();
|
||||
setDbMsg('Database reset to the default location. Restart OpsLog to apply.');
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Database location"
|
||||
hint="Keep your log database wherever you like — another drive or a synced folder (Seafile, Dropbox…) — so it survives a Windows reinstall. Everything (QSOs, settings, lookup cache) lives in this one file."
|
||||
/>
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="space-y-1">
|
||||
<Label>Current database</Label>
|
||||
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
||||
{dbSettings.path || '—'}
|
||||
{dbSettings.is_custom
|
||||
? <span className="ml-2 text-[10px] text-emerald-700">(custom location)</span>
|
||||
: <span className="ml-2 text-[10px] text-muted-foreground">(default)</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">Default: <span className="font-mono">{dbSettings.default_path}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={openExisting}>Open existing database…</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy & switch to it…</Button>
|
||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
||||
<strong>Open existing</strong> points OpsLog at a database file you already have (e.g. after reinstalling Windows).{' '}
|
||||
<strong>Save a copy</strong> writes the current database to a new place and switches to it.{' '}
|
||||
A database change takes effect on the next launch.
|
||||
</div>
|
||||
|
||||
{dbMsg && (
|
||||
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
|
||||
<span>{dbMsg}</span>
|
||||
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>Quit now</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Map sections to their content + icon (for placeholder).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
station: StationPanel,
|
||||
@@ -2134,6 +2245,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
cluster: ClusterPanel,
|
||||
udp: UDPIntegrationsPanelWrapper,
|
||||
backup: BackupPanel,
|
||||
database: DatabasePanel,
|
||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||
cat: CATPanel,
|
||||
rotator: RotatorPanel,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Input } from './input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Searchable combobox: type to filter, click/Enter to pick. On blur it commits
|
||||
// only an exact (case-insensitive) match — otherwise it reverts, so the field
|
||||
// can't hold a typo'd value that isn't in the list.
|
||||
export function Combobox({
|
||||
value, onChange, options, placeholder, className, allowFreeText = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
allowFreeText?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onDoc(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, []);
|
||||
|
||||
const filtered = open
|
||||
? options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60)
|
||||
: [];
|
||||
|
||||
function commit(v: string) {
|
||||
onChange(v);
|
||||
setQuery(v);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
// Defer so a click on an option registers first.
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
const exact = options.find((o) => o.toLowerCase() === query.trim().toLowerCase());
|
||||
if (exact) { onChange(exact); setQuery(exact); }
|
||||
else if (allowFreeText) { onChange(query.trim()); }
|
||||
else { setQuery(value); } // revert typo
|
||||
}, 120);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('relative', className)}>
|
||||
<Input
|
||||
value={open ? query : value}
|
||||
placeholder={placeholder}
|
||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||
// open the list (so tabbing in doesn't pop the dropdown).
|
||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||
else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); }
|
||||
else if (e.key === 'Escape') { setQuery(value); setOpen(false); }
|
||||
// Tab: just let it move on; onBlur commits/closes. Options are
|
||||
// tabIndex=-1 so a single Tab leaves the field.
|
||||
}}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="block w-full text-left px-2 py-1 hover:bg-accent/40"
|
||||
onMouseDown={(e) => { e.preventDefault(); commit(o); }}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,10 +25,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean }
|
||||
>(({ className, children, hideClose, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean }
|
||||
>(({ className, children, hideClose, hideOverlay, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
{!hideOverlay && <DialogOverlay />}
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Maps ADIF DXCC entity numbers to ISO-3166 alpha-2 codes (flagcdn slugs),
|
||||
// so we can show the contacted entity's flag in the UI.
|
||||
//
|
||||
// Why not emoji flags? Windows does NOT render regional-indicator flag
|
||||
// emoji (it shows the two letters instead), and this is a Windows app — so
|
||||
// we use flagcdn.com PNGs keyed by ISO code instead.
|
||||
//
|
||||
// Numbers are taken verbatim from internal/dxcc/adif_numbers.go so they're
|
||||
// guaranteed correct. Coverage is the commonly-worked entities; an unknown
|
||||
// DXCC number simply yields no flag (graceful — better than a wrong one).
|
||||
//
|
||||
// French overseas territories (Corsica, Guadeloupe, Martinique, Réunion,
|
||||
// New Caledonia, French Polynesia…) are mapped to "fr": they fly the French
|
||||
// tricolore and several of their ISO codes aren't served by flagcdn.
|
||||
const DXCC_ISO: Record<number, string> = {
|
||||
// North America
|
||||
1: 'ca', 291: 'us', 6: 'us', 110: 'us', 50: 'mx',
|
||||
202: 'pr', 285: 'vi', 91: 'vg', 69: 'ky', 82: 'jm', 60: 'bs', 64: 'bm',
|
||||
78: 'ht', 72: 'do', 70: 'cu', 62: 'bb', 90: 'tt', 77: 'gd', 97: 'lc',
|
||||
98: 'vc', 95: 'dm', 96: 'ms', 249: 'kn', 94: 'ag', 89: 'tc',
|
||||
79: 'fr', 84: 'fr', 63: 'fr',
|
||||
// Central America
|
||||
66: 'bz', 80: 'hn', 74: 'sv', 86: 'ni', 308: 'cr', 88: 'pa', 76: 'gt',
|
||||
// South America
|
||||
116: 'co', 120: 'ec', 71: 'ec', 136: 'pe', 104: 'bo', 112: 'cl',
|
||||
100: 'ar', 144: 'uy', 132: 'py', 108: 'br', 148: 've', 129: 'gy',
|
||||
140: 'sr', 216: 'co',
|
||||
// Western Europe
|
||||
227: 'fr', 214: 'fr', 230: 'de', 209: 'be', 263: 'nl', 254: 'lu',
|
||||
287: 'ch', 251: 'li', 206: 'at', 248: 'it', 225: 'it', 281: 'es',
|
||||
272: 'pt', 203: 'ad', 278: 'sm', 295: 'va', 260: 'mc',
|
||||
// British Isles
|
||||
223: 'gb-eng', 279: 'gb-sct', 294: 'gb-wls', 265: 'gb-nir', 245: 'ie',
|
||||
114: 'im', 122: 'je', 106: 'gg',
|
||||
// Central / Eastern Europe
|
||||
269: 'pl', 503: 'cz', 504: 'sk', 239: 'hu', 275: 'ro', 212: 'bg',
|
||||
296: 'rs', 514: 'me', 499: 'si', 497: 'hr', 501: 'ba', 502: 'mk',
|
||||
522: 'xk', 7: 'al',
|
||||
// Greece / Mediterranean
|
||||
236: 'gr', 45: 'gr', 40: 'gr', 180: 'gr', 215: 'cy', 257: 'mt', 390: 'tr',
|
||||
// Nordic / Baltic
|
||||
221: 'dk', 222: 'fo', 237: 'gl', 284: 'se', 266: 'no', 224: 'fi',
|
||||
5: 'ax', 242: 'is', 52: 'ee', 145: 'lv', 146: 'lt',
|
||||
// Eastern Europe / Caucasus / Russia
|
||||
27: 'by', 288: 'ua', 179: 'md', 75: 'ge', 14: 'am', 18: 'az',
|
||||
54: 'ru', 15: 'ru', 126: 'ru',
|
||||
// Middle East
|
||||
336: 'il', 342: 'jo', 354: 'lb', 384: 'sy', 378: 'sa', 391: 'ae',
|
||||
304: 'bh', 348: 'kw', 376: 'qa', 370: 'om', 492: 'ye', 330: 'ir', 333: 'iq',
|
||||
// North Africa
|
||||
478: 'eg', 436: 'ly', 400: 'dz', 446: 'ma', 302: 'eh', 474: 'tn',
|
||||
// Sub-Saharan Africa
|
||||
462: 'za', 464: 'na', 402: 'bw', 452: 'zw', 482: 'zm', 181: 'mz',
|
||||
438: 'mg', 165: 'mu', 453: 'fr', 379: 'sc', 430: 'ke', 470: 'tz',
|
||||
286: 'ug', 53: 'et', 51: 'er', 466: 'sd', 521: 'ss', 450: 'ng',
|
||||
424: 'gh', 406: 'cm', 456: 'sn', 434: 'lr', 458: 'sl', 416: 'bj',
|
||||
483: 'tg', 428: 'ci', 442: 'ml', 187: 'ne', 410: 'td', 32: 'cv',
|
||||
420: 'ga', 444: 'mr', 382: 'dj', 454: 'rw', 107: 'gn', 39: 'km', 169: 'fr',
|
||||
// Asia
|
||||
339: 'jp', 137: 'kr', 344: 'kp', 318: 'cn', 324: 'in', 372: 'pk',
|
||||
315: 'lk', 369: 'np', 305: 'bd', 306: 'bt', 309: 'mm', 299: 'my',
|
||||
46: 'my', 381: 'sg', 327: 'id', 375: 'ph', 345: 'bn', 312: 'kh',
|
||||
143: 'la', 387: 'th', 293: 'vn', 130: 'kz', 292: 'uz', 280: 'tm',
|
||||
262: 'tj', 135: 'kg', 3: 'af', 159: 'mv', 363: 'mn', 321: 'hk',
|
||||
386: 'tw', 511: 'tl',
|
||||
// Oceania
|
||||
150: 'au', 163: 'pg', 185: 'sb', 158: 'vu', 176: 'fj', 190: 'ws',
|
||||
170: 'nz', 162: 'fr', 175: 'fr', 160: 'to', 282: 'tv', 301: 'ki',
|
||||
188: 'nu', 168: 'mh', 189: 'nf', 147: 'au',
|
||||
};
|
||||
|
||||
// flagURL returns a flagcdn PNG URL for the given DXCC entity number, or ''
|
||||
// when we don't have a mapping. Height ~20px by default (retina-friendly).
|
||||
export function flagURL(dxcc?: number): string {
|
||||
if (!dxcc) return '';
|
||||
const iso = DXCC_ISO[dxcc];
|
||||
return iso ? `https://flagcdn.com/h20/${iso}.png` : '';
|
||||
}
|
||||
Vendored
+20
@@ -67,6 +67,8 @@ export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||
|
||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
||||
|
||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
@@ -79,6 +81,8 @@ export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||
|
||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||
|
||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||
|
||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||
|
||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||
@@ -89,6 +93,8 @@ export function ImportADIF(arg1:string,arg2:boolean):Promise<adif.ImportResult>;
|
||||
|
||||
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
||||
|
||||
export function ListCountries():Promise<Array<string>>;
|
||||
|
||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||
|
||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||
@@ -103,18 +109,30 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function MoveDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function OpenADIFFile():Promise<string>;
|
||||
|
||||
export function OpenDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
||||
|
||||
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
||||
|
||||
export function PickBackupFolder():Promise<string>;
|
||||
|
||||
export function PickOpenDatabase():Promise<string>;
|
||||
|
||||
export function PickSaveDatabase():Promise<string>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function ResetDatabaseToDefault():Promise<void>;
|
||||
|
||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function RotatorPark():Promise<void>;
|
||||
@@ -153,6 +171,8 @@ export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||
|
||||
export function SendClusterCommand(arg1:string):Promise<void>;
|
||||
|
||||
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
||||
|
||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
@@ -114,6 +114,10 @@ export function GetCtyDatInfo() {
|
||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||
}
|
||||
|
||||
export function GetDatabaseSettings() {
|
||||
return window['go']['main']['App']['GetDatabaseSettings']();
|
||||
}
|
||||
|
||||
export function GetExternalServices() {
|
||||
return window['go']['main']['App']['GetExternalServices']();
|
||||
}
|
||||
@@ -138,6 +142,10 @@ export function GetQSO(arg1) {
|
||||
return window['go']['main']['App']['GetQSO'](arg1);
|
||||
}
|
||||
|
||||
export function GetRotatorHeading() {
|
||||
return window['go']['main']['App']['GetRotatorHeading']();
|
||||
}
|
||||
|
||||
export function GetRotatorSettings() {
|
||||
return window['go']['main']['App']['GetRotatorSettings']();
|
||||
}
|
||||
@@ -158,6 +166,10 @@ export function ListClusterServers() {
|
||||
return window['go']['main']['App']['ListClusterServers']();
|
||||
}
|
||||
|
||||
export function ListCountries() {
|
||||
return window['go']['main']['App']['ListCountries']();
|
||||
}
|
||||
|
||||
export function ListOperatingTree() {
|
||||
return window['go']['main']['App']['ListOperatingTree']();
|
||||
}
|
||||
@@ -186,10 +198,18 @@ export function LookupCallsign(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||
}
|
||||
|
||||
export function MoveDatabase(arg1) {
|
||||
return window['go']['main']['App']['MoveDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function OpenADIFFile() {
|
||||
return window['go']['main']['App']['OpenADIFFile']();
|
||||
}
|
||||
|
||||
export function OpenDatabase(arg1) {
|
||||
return window['go']['main']['App']['OpenDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function OpenExternalURL(arg1) {
|
||||
return window['go']['main']['App']['OpenExternalURL'](arg1);
|
||||
}
|
||||
@@ -202,6 +222,18 @@ export function PickBackupFolder() {
|
||||
return window['go']['main']['App']['PickBackupFolder']();
|
||||
}
|
||||
|
||||
export function PickOpenDatabase() {
|
||||
return window['go']['main']['App']['PickOpenDatabase']();
|
||||
}
|
||||
|
||||
export function PickSaveDatabase() {
|
||||
return window['go']['main']['App']['PickSaveDatabase']();
|
||||
}
|
||||
|
||||
export function QuitApp() {
|
||||
return window['go']['main']['App']['QuitApp']();
|
||||
}
|
||||
|
||||
export function RefreshCtyDat() {
|
||||
return window['go']['main']['App']['RefreshCtyDat']();
|
||||
}
|
||||
@@ -210,6 +242,10 @@ export function ReloadUDPIntegrations() {
|
||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||
}
|
||||
|
||||
export function ResetDatabaseToDefault() {
|
||||
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
||||
}
|
||||
|
||||
export function RotatorGoTo(arg1, arg2) {
|
||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||
}
|
||||
@@ -286,6 +322,10 @@ export function SendClusterCommand(arg1) {
|
||||
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
||||
}
|
||||
|
||||
export function SendClusterSpot(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function SetCATFrequency(arg1) {
|
||||
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
||||
}
|
||||
|
||||
@@ -413,6 +413,22 @@ export namespace main {
|
||||
this.file_mod_time = source["file_mod_time"];
|
||||
}
|
||||
}
|
||||
export class DatabaseSettings {
|
||||
path: string;
|
||||
default_path: string;
|
||||
is_custom: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new DatabaseSettings(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.path = source["path"];
|
||||
this.default_path = source["default_path"];
|
||||
this.is_custom = source["is_custom"];
|
||||
}
|
||||
}
|
||||
export class ModePreset {
|
||||
name: string;
|
||||
default_rst_sent?: string;
|
||||
@@ -432,6 +448,9 @@ export namespace main {
|
||||
export class ListsSettings {
|
||||
bands: string[];
|
||||
modes: ModePreset[];
|
||||
rst_phone: string[];
|
||||
rst_cw: string[];
|
||||
rst_digital: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ListsSettings(source);
|
||||
@@ -441,6 +460,9 @@ export namespace main {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.bands = source["bands"];
|
||||
this.modes = this.convertValues(source["modes"], ModePreset);
|
||||
this.rst_phone = source["rst_phone"];
|
||||
this.rst_cw = source["rst_cw"];
|
||||
this.rst_digital = source["rst_digital"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -516,6 +538,24 @@ export namespace main {
|
||||
this.qrzcom_status = source["qrzcom_status"];
|
||||
}
|
||||
}
|
||||
export class RotatorHeading {
|
||||
enabled: boolean;
|
||||
ok: boolean;
|
||||
azimuth: number;
|
||||
raw: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RotatorHeading(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.ok = source["ok"];
|
||||
this.azimuth = source["azimuth"];
|
||||
this.raw = source["raw"];
|
||||
}
|
||||
}
|
||||
export class RotatorSettings {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
@@ -955,6 +995,8 @@ export namespace qso {
|
||||
hrdlog_qso_upload_status?: string;
|
||||
qrzcom_qso_upload_date?: string;
|
||||
qrzcom_qso_upload_status?: string;
|
||||
qrzcom_qso_download_date?: string;
|
||||
qrzcom_qso_download_status?: string;
|
||||
contest_id?: string;
|
||||
srx?: number;
|
||||
stx?: number;
|
||||
@@ -1060,6 +1102,8 @@ export namespace qso {
|
||||
this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"];
|
||||
this.qrzcom_qso_upload_date = source["qrzcom_qso_upload_date"];
|
||||
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
|
||||
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
|
||||
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
|
||||
this.contest_id = source["contest_id"];
|
||||
this.srx = source["srx"];
|
||||
this.stx = source["stx"];
|
||||
|
||||
Reference in New Issue
Block a user