Files
OpsLog/frontend/src/App.tsx
T
2026-06-06 11:59:32 +02:00

2990 lines
142 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X,
} from 'lucide-react';
import {
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus,
WorkedBefore,
SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
RefreshCtyDat,
RotatorGoTo, RotatorStop, GetRotatorHeading,
OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
GetCATSettings,
OperatingDefaultForBand,
LogUDPLoggedADIF,
ListCountries,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
QSOAudioBegin, QSOAudioCancel,
GetAwardDefs,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
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 { QSLManagerPanel } from '@/components/QSLManagerModal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
import { MainMap } from '@/components/MainMap';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ShutdownProgress } from '@/components/ShutdownProgress';
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 { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} 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;
type LookupResult = lookupModels.Result;
type StationSettings = StationSettingsForm;
type ListsSettings = ListsSettingsForm;
type ModePreset = ModePresetForm;
type WB = WorkedBeforeView;
type CATState = Omit<catModels.RigState, 'convertValues'>;
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
// Modes the QSO recorder captures (voice + CW). Mirrors recordableMode() in
// app.go — digital modes carry no useful audio and are never recorded.
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV','CW']);
const emptyDetails: DetailsState = {
state: '', cnty: '', address: '',
lat: undefined, lon: undefined,
dxcc: undefined, cqz: undefined, ituz: undefined, cont: '',
qsl_msg: '', qsl_via: '',
ant_az: undefined, ant_el: undefined, ant_path: '',
prop_mode: '', my_rig: '', my_antenna: '',
tx_pwr: undefined,
sat_name: '', sat_mode: '',
contest_id: '', srx: undefined, stx: undefined,
email: '',
award_refs: '',
};
function fmtDateUTC(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return s;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
function fmtFreq(hz?: number): string {
if (!hz) return '';
return (hz / 1_000_000).toFixed(4);
}
// cleanSpotter / inferSpotMode / spotStatusKey live in lib/spot.ts so
// the BandMap component reads from the same canonical source — keeps
// "CW spot looks like CW everywhere" honest.
function fmtHMSUTC(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
}
// parseHMSUTC parses "HH:MM" or "HH:MM:SS" and returns a Date with that
// UTC time on the same UTC day as base. Tolerates "HHMM" / "HHMMSS" too
// (no separators) so the user can type fast. Falls back to base on bad input.
function parseHMSUTC(s: string, base: Date): Date {
const clean = s.replace(/[^0-9:]/g, '');
let h = 0, m = 0, sec = 0;
if (clean.includes(':')) {
const parts = clean.split(':');
h = parseInt(parts[0] ?? '0', 10) || 0;
m = parseInt(parts[1] ?? '0', 10) || 0;
sec = parseInt(parts[2] ?? '0', 10) || 0;
} else if (clean.length >= 4) {
h = parseInt(clean.slice(0, 2), 10) || 0;
m = parseInt(clean.slice(2, 4), 10) || 0;
sec = clean.length >= 6 ? (parseInt(clean.slice(4, 6), 10) || 0) : 0;
} else {
return base;
}
if (h > 23 || m > 59 || sec > 59) return base;
const d = new Date(base);
d.setUTCHours(h, m, sec, 0);
return d;
}
// fmtFreqDots formats a MHz string for display as MHz.kHz.Hz with dot
// separators (14.266500 → "14.266.500"). Pads the fractional part to 6
// digits so partial inputs ("14.21") still render as "14.210.000".
function fmtFreqDots(mhzStr: string): string {
if (!mhzStr) return '';
const [intPart, fracRaw = ''] = mhzStr.split('.');
const frac = (fracRaw + '000000').slice(0, 6);
return `${intPart}.${frac.slice(0, 3)}.${frac.slice(3, 6)}`;
}
// shortCatError condenses a backend error into a few words for the topbar
// pill. The full message stays in the tooltip. Recognises the common cases
// (OmniRig not installed, not registered) and otherwise truncates.
function shortCatError(err?: string): string {
if (!err) return '';
const e = err.toLowerCase();
if (e.includes('not registered') || e.includes('not available')) return 'OmniRig not found';
if (e.includes('not connected')) return 'not connected';
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];
let lastDigit = -1;
for (let i = 0; i < c.length; i++) {
if (c[i] >= '0' && c[i] <= '9') lastDigit = i;
}
return lastDigit >= 0 ? c.slice(0, lastDigit + 1) : c;
}
export default function App() {
// === Lists from settings (fallback for first paint) ===
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
const [modePresets, setModePresets] = useState<ModePreset[]>([]);
// === 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);
// Frozen end time used when the End-UTC field is locked (backdated QSO);
// null means the field tracks the live clock.
const [qsoEndedAt, setQsoEndedAt] = useState<Date | null>(null);
// Local string buffers for the time inputs while the user is editing.
// Parsing only on blur — otherwise every keystroke would re-format the
// value and React would move the cursor back to the end of the field.
const [startInputStr, setStartInputStr] = useState('');
const [endInputStr, setEndInputStr] = useState('');
const [startFocused, setStartFocused] = useState(false);
const [endFocused, setEndFocused] = useState(false);
const [freqFocused, setFreqFocused] = useState(false);
// Per-field locks — like Log4OM's padlocks. When locked, CAT updates skip
// that field and the time fields become editable so the user can log a
// QSO from the past while the radio is parked elsewhere.
type LockKey = 'band' | 'mode' | 'freq' | 'start' | 'end';
const [locks, setLocks] = useState<Record<LockKey, boolean>>({
band: false, mode: false, freq: false, start: false, end: false,
});
const locksRef = useRef(locks);
useEffect(() => { locksRef.current = locks; }, [locks]);
const toggleLock = (k: LockKey) => {
setLocks((s) => {
const wasLocked = s[k];
const next = { ...s, [k]: !wasLocked };
// Unlocking → restore automatic behavior. Without this the locked
// value would linger forever: a stale Start time would never refresh
// even after a new callsign is entered.
if (wasLocked) {
if (k === 'start') {
// If a QSO is currently in progress (callsign typed), snap start
// to now since we missed the auto-start moment. Otherwise clear.
setQsoStartedAt(callsign.trim() ? new Date() : null);
} else if (k === 'end') {
// Drop the frozen end so the field tracks the live UTC clock.
setQsoEndedAt(null);
}
}
return next;
});
};
// Small padlock toggle rendered inside each lockable field's label. Match
// the icon to the current state so the user can tell at a glance which
// fields are immune to CAT updates / live clock.
function LockBtn({ k, title }: { k: LockKey; title: string }) {
const on = locks[k];
const Icon = on ? Lock : Unlock;
return (
<button
type="button"
tabIndex={-1}
onClick={() => toggleLock(k)}
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
className={cn(
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
on ? 'text-amber-600 hover:text-amber-700' : 'text-muted-foreground/40 hover:text-muted-foreground',
)}
>
<Icon className="size-3" />
</button>
);
}
const [band, setBand] = useState('20m');
const [mode, setMode] = useState('SSB');
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('');
const [name, setName] = useState('');
const [qth, setQth] = useState('');
const [country, setCountry] = useState('');
const [comment, setComment] = useState('');
const [note, setNote] = useState('');
// Compact (popout) mode: shrinks the window + always-on-top so the entry
// strip can sit on top of WSJT-X / JT-Alert / the cluster.
const [compact, setCompact] = useState(false);
function toggleCompact() {
const next = !compact;
setCompact(next);
SetCompactMode(next);
}
// 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.
const digitalDefaultRef = useRef<string>('FT8');
// Don't override freq/band/mode the user JUST typed — track a small grace
// window after manual edits and skip CAT updates during it.
const catFreezeUntilRef = useRef<number>(0);
function noteManualEdit() { catFreezeUntilRef.current = Date.now() + 1500; }
// Suggested QSY frequency (Hz) for a given band + mode. Common phone /
// CW / FT8 watering holes per IARU practice. Fallback = mid-band SSB freq.
// TODO: make this a user preference per band/mode later.
const QSY_TABLE: Record<string, Record<string, number>> = {
'160m': { SSB: 1842000, CW: 1820000, FT8: 1840000, FT4: 1840000, RTTY: 1838000, default: 1840000 },
'80m': { SSB: 3750000, CW: 3520000, FT8: 3573000, FT4: 3575000, RTTY: 3580000, default: 3750000 },
'60m': { default: 5357000 },
'40m': { SSB: 7150000, CW: 7030000, FT8: 7074000, FT4: 7047500, RTTY: 7040000, default: 7150000 },
'30m': { CW: 10116000, FT8: 10136000, FT4: 10140000, RTTY: 10142000, default: 10136000 },
'20m': { SSB: 14250000, CW: 14030000, FT8: 14074000, FT4: 14080000, RTTY: 14080000, default: 14250000 },
'17m': { SSB: 18140000, CW: 18080000, FT8: 18100000, FT4: 18104000, RTTY: 18105000, default: 18140000 },
'15m': { SSB: 21300000, CW: 21030000, FT8: 21074000, FT4: 21140000, RTTY: 21080000, default: 21300000 },
'12m': { SSB: 24950000, CW: 24910000, FT8: 24915000, FT4: 24919000, RTTY: 24920000, default: 24950000 },
'10m': { SSB: 28500000, CW: 28030000, FT8: 28074000, FT4: 28180000, RTTY: 28080000, default: 28500000 },
'6m': { SSB: 50150000, CW: 50080000, FT8: 50313000, FT4: 50318000, default: 50150000 },
'2m': { SSB: 144300000, CW: 144050000, FT8: 144174000, default: 144300000 },
'70cm': { SSB: 432300000, CW: 432050000, FT8: 432174000, default: 432300000 },
};
function qsyFreqHz(band: string, mode: string): number {
const t = QSY_TABLE[band];
if (!t) return 0;
return t[mode] ?? t.default ?? 0;
}
// Digital watering holes (±3 kHz tolerance). When the rig is parked here
// CAT typically reports SSB (USB-D under the hood) which is misleading —
// auto-promote to the specific digital mode for the entry strip.
const DIGITAL_FREQS: { freq: number; mode: string }[] = [
{ freq: 1.840, mode: 'FT8' },
{ freq: 3.573, mode: 'FT8' },
{ freq: 5.357, mode: 'FT8' },
{ freq: 7.074, mode: 'FT8' }, { freq: 7.0475, mode: 'FT4' },
{ freq: 10.136, mode: 'FT8' }, { freq: 10.140, mode: 'FT4' },
{ freq: 14.074, mode: 'FT8' }, { freq: 14.080, mode: 'FT4' },
{ freq: 18.100, mode: 'FT8' }, { freq: 18.104, mode: 'FT4' },
{ freq: 21.074, mode: 'FT8' }, { freq: 21.140, mode: 'FT4' },
{ freq: 24.915, mode: 'FT8' }, { freq: 24.919, mode: 'FT4' },
{ freq: 28.074, mode: 'FT8' }, { freq: 28.180, mode: 'FT4' },
{ freq: 50.313, mode: 'FT8' }, { freq: 50.318, mode: 'FT4' },
{ freq: 144.174, mode: 'FT8' },
];
function inferDigitalMode(freqHz: number): string {
const mhz = freqHz / 1_000_000;
for (const d of DIGITAL_FREQS) {
if (Math.abs(mhz - d.freq) <= 0.003) return d.mode;
}
return '';
}
// User changed band/mode in the entry strip → push to the rig if CAT is up.
// Both calls are fire-and-forget; CAT will reflect back via cat:state.
function onBandUserChange(v: string) {
setBand(v);
noteManualEdit();
if (catState.enabled && catState.connected) {
const hz = qsyFreqHz(v, mode);
if (hz > 0) SetCATFrequency(hz).catch(() => {});
}
}
function onModeUserChange(v: string) {
setMode(v);
applyModePreset(v);
noteManualEdit();
if (catState.enabled && catState.connected) {
SetCATMode(v).catch(() => {});
}
}
const userEditedRef = useRef<Set<string>>(new Set());
const lastLookedUpRef = useRef<string>('');
// Tracks the call we last auto-switched to the Worked-before tab for, so we
// don't keep yanking the tab on every wb refresh of the same callsign.
const lastWbFocusRef = useRef<string>('');
const rstUserEditedRef = useRef<boolean>(false);
const [details, setDetails] = useState<DetailsState>(emptyDetails);
const updateDetails = useCallback((patch: Partial<DetailsState>) => {
setDetails((d) => ({ ...d, ...patch }));
}, []);
// Auto-fill MY_RIG / MY_ANTENNA from the operating conditions tree
// whenever the band changes. The backend resolves the "default antenna
// for this band" within the active profile and returns the (rig,
// antenna) tuple. Empty result → we DO clear the fields so leftover
// values from a previous band don't get logged against the wrong gear.
useEffect(() => {
if (!band) return;
let cancelled = false;
OperatingDefaultForBand(band).then((d) => {
if (cancelled) return;
setDetails((cur) => ({
...cur,
my_rig: d?.station_name || '',
my_antenna: d?.antenna_name || '',
tx_pwr: d?.tx_pwr ?? cur.tx_pwr,
}));
}).catch(() => {});
return () => { cancelled = true; };
}, [band]);
const prefix = useMemo(() => computePrefix(callsign), [callsign]);
// Bearing/distance from operator's home grid to the remote station —
// shown live in the entry strip (SP azimuth) and Info tab (LP + dist).
// === Logbook list ===
const [qsos, setQsos] = useState<QSO[]>([]);
const [total, setTotal] = useState<number>(0);
const [error, setError] = useState('');
const [migratedBanner, setMigratedBanner] = useState(false);
// 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);
}, []);
// Error banners auto-dismiss after a few seconds (longer than toasts since
// they may be multi-line). The X button still closes them immediately.
useEffect(() => {
if (!error) return;
const t = window.setTimeout(() => setError(''), 6000);
return () => window.clearTimeout(t);
}, [error]);
// True while the QSO recorder is capturing the current contact (set when we
// leave the callsign field, cleared on log/cancel). Drives the REC badge.
const [recording, setRecording] = useState(false);
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
// second while a recording is in progress.
const [recSeconds, setRecSeconds] = useState(0);
useEffect(() => {
if (!recording) { setRecSeconds(0); return; }
const start = Date.now();
setRecSeconds(0);
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
return () => window.clearInterval(id);
}, [recording]);
const [saving, setSaving] = useState(false);
const [filterCallsign, setFilterCallsign] = useState('');
// Advanced filter builder (replaces the old band/mode dropdowns).
const [filterOpen, setFilterOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
const [matchCount, setMatchCount] = useState<number | null>(null);
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
// to 500 keeps the first paint instant; the user can bump to "All"
// when they actually want to search history.
const [qsoLimit, setQsoLimit] = useState<number>(() => {
const raw = Number(localStorage.getItem('hamlog.qsoLimit') ?? '500');
return Number.isFinite(raw) && raw > 0 ? raw : 500;
});
useEffect(() => { localStorage.setItem('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
// === DX Cluster live state ===
type ClusterSpot = {
source_id: number;
source_name: string;
spotter: string;
dx_call: string;
freq_khz: number;
freq_hz: number;
band?: string;
comment?: string;
locator?: string;
time_utc?: string;
country?: string;
continent?: string;
cqz?: number;
ituz?: number;
distance_km?: number;
sp_deg?: number;
lp_deg?: number;
received_at: string;
raw: string;
};
type ServerStatus = {
server_id: number;
name: string;
host: string;
port: number;
state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
login?: string;
error?: string;
spots_count?: number;
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);
// "You have been spotted" banner — set when a cluster spot's DX call is our
// own station callsign. Ref holds our call for the (one-shot) spot listener.
const [selfSpot, setSelfSpot] = useState<{ spotter: string; freqKHz: number; band?: string; comment?: string; at: number } | null>(null);
const myCallRef = useRef('');
const selfSpotTimerRef = useRef<number | null>(null);
// === WinKeyer CW keyer ===
const [wkEnabled, setWkEnabled] = useState(false);
const [wkPort, setWkPort] = useState('');
const [wkWpm, setWkWpm] = useState(25);
const [wkMacros, setWkMacros] = useState<WKMacro[]>([]);
const [wkPorts, setWkPorts] = useState<string[]>([]);
const [wkStatus, setWkStatus] = useState<WKStatus>({ connected: false, busy: false, wpm: 25, version: 0, port: '' });
const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
// F1-F12 macro shortcuts active only when the keyer is enabled + connected.
const wkActiveRef = useRef(false);
const wkEscClearsRef = useRef(true);
useEffect(() => { wkActiveRef.current = wkEnabled && wkStatus.connected; }, [wkEnabled, wkStatus.connected]);
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
// === Digital Voice Keyer (DVK) ===
const [dvkEnabled, setDvkEnabled] = useState(false);
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
const dvkActiveRef = useRef(false);
const dvkPlayingRef = useRef(false);
const dvkPlayRef = useRef<(slot: number) => void>(() => {});
useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]);
useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]);
useEffect(() => {
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
return () => { off?.(); };
}, []);
const reloadDvk = useCallback(() => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); }, []);
// Load messages + status whenever the keyer is switched on.
useEffect(() => {
if (!dvkEnabled) return;
reloadDvk();
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
}, [dvkEnabled, reloadDvk]);
const dvkPlay = useCallback((slot: number) => { DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e))); }, []);
useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]);
// Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch
// it from the keyboard without clashing with the F1-F12 keyer macros).
type DetailTab = 'stats' | 'info' | 'awards' | 'my' | 'extended';
const [detailTab, setDetailTab] = useState<DetailTab>('stats');
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[]>([]);
const SPOTS_CAP = 1000;
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
const [clusterGroup, setClusterGroup] = useState(true);
const [clusterCmd, setClusterCmd] = useState('');
// Multi-band filter: empty set = all bands. The user toggles chips.
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
// Lock-to-entry: when on, the band filter follows the entry's current
// band and the mode filter follows the entry's current mode.
const [clusterLockBand, setClusterLockBand] = useState(false);
const [clusterLockMode, setClusterLockMode] = useState(false);
// Status filter chips. Empty set = show every status (including
// already-worked). Otherwise only matching spots pass.
type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
// Mode filter chips. Empty set = show every mode. Categories map the
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
const [clusterSearch, setClusterSearch] = useState('');
const [showBandMap, setShowBandMap] = useState(false);
// Which side the band map docks to (persisted). Toggled from its header.
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
() => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
);
const toggleBandMapSide = useCallback(() => {
setBandMapSide((s) => {
const next = s === 'right' ? 'left' : 'right';
localStorage.setItem('bandmap.side', next);
return next;
});
}, []);
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
// different slots don't share the same colour.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean }>>({});
// === Modals ===
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [showSettings, setShowSettings] = useState(false);
// Optional deep-link: which Preferences section to open. Cleared on
// close so the next plain "Preferences" launch reverts to default.
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
const [showDeleteAll, setShowDeleteAll] = useState(false);
const [deletingAll, setDeletingAll] = useState(false);
const [ctyRefreshing, setCtyRefreshing] = useState(false);
// === ADIF ===
const [importing, setImporting] = useState(false);
const [importProgress, setImportProgress] = useState<{ processed: number; total: number } | null>(null);
const [exporting, setExporting] = useState(false);
// Export mode chooser: standard ADIF (other loggers) vs full (OpsLog round-trip).
const [showExportChoice, setShowExportChoice] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
const [importDupsOpen, setImportDupsOpen] = useState(false);
// ADIF import confirmation: after the user picks a file, hold the path
// until they confirm the options (skip duplicates etc.).
const [pendingImportPath, setPendingImportPath] = useState<string | null>(null);
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
const [importApplyCty, setImportApplyCty] = useState(true);
// QRZ profile photo lightbox (full-size, in-app — not the browser).
const [photoModal, setPhotoModal] = useState<string | null>(null);
// Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
// global ESC handler (which resets the entry) doesn't also fire.
useEffect(() => {
if (!photoModal) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); setPhotoModal(null); }
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [photoModal]);
// === Lookup + WB ===
const [lookupResult, setLookupResult] = useState<LookupResult | null>(null);
const [lookupBusy, setLookupBusy] = useState(false);
const [lookupError, setLookupError] = useState('');
const lookupTimerRef = useRef<number | null>(null);
const wbTimerRef = useRef<number | null>(null);
const [wb, setWb] = useState<WB | null>(null);
const [wbBusy, setWbBusy] = useState(false);
// Always-current copy of the entry callsign, so the UDP event handlers
// (which live in a []-deps effect with a stale `callsign` closure) can
// tell whether an incoming DX call actually changed anything.
const callsignValRef = useRef('');
useEffect(() => { callsignValRef.current = callsign; }, [callsign]);
// When the entered callsign turns out to be worked-before, jump to the
// Worked-before tab so the history is front-and-centre. Only once per call,
// and we don't yank the user out of the Cluster / QSL-manager tabs.
useEffect(() => {
// Opt-out: General settings can disable this auto-jump (read live so the
// toggle takes effect without a reload).
if (localStorage.getItem('opslog.autofocusWB') === '0') return;
const c = callsign.trim().toUpperCase();
if (!c || !wb || (wb.count ?? 0) <= 0 || (wb.callsign ?? '').toUpperCase() !== c) return;
if (lastWbFocusRef.current === c) return;
lastWbFocusRef.current = c;
setActiveTab((t) => (t === 'cluster' || t === 'qsl' ? t : 'worked'));
}, [wb, callsign]);
// === Station ===
const [station, setStation] = useState<StationSettings>({
callsign: '', operator: '',
my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '',
});
myCallRef.current = (station.callsign || '').toUpperCase();
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
// picked award references to the QSO field/extras each award actually reads.
const awardFieldRef = useRef<Record<string, string>>({});
useEffect(() => {
GetAwardDefs()
.then((defs) => {
const m: Record<string, string> = {};
for (const d of (defs ?? []) as any[]) m[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
awardFieldRef.current = m;
})
.catch(() => {});
}, []);
// === Clock ===
const [utcNow, setUtcNow] = useState('');
useEffect(() => {
function tick() {
const d = new Date();
const p = (n: number) => String(n).padStart(2, '0');
setUtcNow(`${d.getUTCFullYear()}-${p(d.getUTCMonth()+1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`);
}
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, []);
// The full filter sent to the backend = the builder conditions + the quick
// callsign search box (always ANDed) + the on-screen row threshold.
const buildActiveFilter = useCallback((): QueryFilter => ({
quick_callsign: filterCallsign,
conditions: activeFilter.conditions ?? [],
match: activeFilter.match ?? 'AND',
limit: qsoLimit,
offset: 0,
}), [filterCallsign, activeFilter, qsoLimit]);
const refresh = useCallback(async () => {
try {
const f = buildActiveFilter();
const list = await ListQSOFiltered(f as any);
const n = await CountQSO();
const hasFilter = !!(f.quick_callsign || (f.conditions && f.conditions.length));
const matched = hasFilter ? await CountQSOFiltered(f as any) : n;
setQsos(list);
setTotal(n);
setMatchCount(matched);
setError('');
} catch (e: any) {
setError(String(e?.message ?? e));
}
}, [buildActiveFilter]);
// Refresh the Recent QSOs grid after external-service uploads stamp the
// sent status (auto-upload via extsvc:uploaded, or manual QSL Manager via
// qslmgr:done). Debounced so a batch of per-QSO events triggers one reload.
useEffect(() => {
let t: number | undefined;
const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
const offUploaded = EventsOn('extsvc:uploaded', ping);
const offDone = EventsOn('qslmgr:done', ping);
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 {}
}, []);
const loadCATCfg = useCallback(async () => {
try {
const c = await GetCATSettings();
if (c.digital_default) digitalDefaultRef.current = c.digital_default;
} catch {}
}, []);
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);
const names = l.modes.map((m) => m.name);
setModes(names);
setMode((cur) => names.includes(cur) ? cur : names[0]);
const preset = l.modes.find((m) => m.name === mode) ?? l.modes[0];
if (preset && !rstUserEditedRef.current) {
if (preset.default_rst_sent) setRstSent(preset.default_rst_sent);
if (preset.default_rst_rcvd) setRstRcvd(preset.default_rst_rcvd);
}
}
} catch {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function applyModePreset(m: string) {
if (rstUserEditedRef.current) return;
// Prefer the user's configured preset RST; otherwise fall back to the mode
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
// so switching SSB→CW flips 59→599 even without a configured preset.
const p = modePresets.find((x) => x.name === m);
const fallback = rstOptions(m, rstLists)[0] || '';
setRstSent(p?.default_rst_sent || fallback);
setRstRcvd(p?.default_rst_rcvd || fallback);
}
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
(async () => {
try {
const st = await GetStartupStatus();
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
if ((st as any).migrated_from_app_data) setMigratedBanner(true);
} catch {}
loadStation();
loadLists();
loadCATCfg();
// Hydrate CAT state on mount (the backend may already be polling).
try { setCatState(await GetCATState()); } catch {}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// CAT live updates. Push freq/band/mode into the entry strip when the rig
// moves, unless the user just typed something (1.5s grace window).
useEffect(() => {
const unsub = EventsOn('cat:state', (s: CATState) => {
setCatState(s);
if (!s?.connected) return;
if (Date.now() < catFreezeUntilRef.current) return;
const lk = locksRef.current;
if (!lk.freq && s.freq_hz && s.freq_hz > 0) {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
}
// 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 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);
// Mode resolution priority:
// 1. If freq matches a known digital watering hole, pick the specific
// mode for that hole (FT8 / FT4) — beats whatever CAT reports.
// 2. Else if CAT reports DATA (generic), use the user's configured
// default digital mode (FT8 by default).
// 3. Else trust CAT (SSB, CW, AM, FM…).
if (!lk.mode) {
const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
if (inferred) {
setMode(inferred);
} else if (s.mode === 'DATA') {
setMode(digitalDefaultRef.current || 'FT8');
} else if (s.mode) {
setMode(s.mode);
}
}
});
return () => { unsub?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Cluster live wiring: hydrate per-server status + saved server list,
// then subscribe to push events.
async function reloadClusterMeta() {
try {
const [st, list] = await Promise.all([GetClusterStatus(), ListClusterServers()]);
setClusterServerStatuses((st ?? []) as ServerStatus[]);
setClusterServers(((list ?? []) as any[]).map((s) => ({
id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0,
})));
} catch {}
}
useEffect(() => {
reloadClusterMeta();
// cluster:state fires on connect/disconnect/save/delete — refresh
// the saved-server list too so the source dropdown stays in sync
// when the user adds, deletes or toggles a row in Settings.
const unsubState = EventsOn('cluster:state', async (sts: ServerStatus[]) => {
setClusterServerStatuses(sts ?? []);
try {
const list = await ListClusterServers();
setClusterServers(((list ?? []) as any[]).map((s) => ({
id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0,
})));
} catch {}
// Drop any buffered spots whose source server is no longer in the
// status list (it was disconnected / deleted). Without this the
// table keeps showing stale rows from a server the user just
// turned off.
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
});
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
setSpots((arr) => {
const next = [sp, ...arr];
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
});
// Self-spot: someone spotted OUR callsign on the cluster.
const mine = myCallRef.current;
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
setSelfSpot({ spotter: cleanSpotter(sp.spotter ?? ''), freqKHz: sp.freq_khz, band: sp.band, comment: sp.comment, at: Date.now() });
// Auto-hide 3 s after the last self-spot; a new one resets the timer.
if (selfSpotTimerRef.current) window.clearTimeout(selfSpotTimerRef.current);
selfSpotTimerRef.current = window.setTimeout(() => setSelfSpot(null), 3000);
}
});
return () => { unsubState?.(); unsubSpot?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── UDP integration events ───────────────────────────────────────────
// Live updates from external apps (WSJT-X / JTDX / MSHV / DXHunter…).
// We push the broadcast DX call into the entry field and auto-log any
// ADIF record that arrives.
useEffect(() => {
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
const call = String(p?.call ?? '').trim();
if (!call) return;
// Don't clobber what the user is currently typing — only update
// when the entry field is empty or matches a previous broadcast.
onCallsignInput(call);
});
const unsubRC = EventsOn('udp:remote_call', (call: string) => {
if (call) onCallsignInput(String(call).trim());
});
const unsubProg = EventsOn('import:progress', (p: any) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
});
const unsubLog = EventsOn('udp:logged_qso', async (p: any) => {
const text = String(p?.adif ?? '').trim();
if (!text) return;
try {
await LogUDPLoggedADIF(text);
await refresh();
} catch (e: any) {
const msg = String(e?.message ?? e);
// A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign —
// show a quiet toast, not a red error.
if (/duplicate/i.test(msg)) showToast('UDP QSO already logged — skipped');
else setError('UDP auto-log: ' + msg);
}
});
return () => { unsubDX?.(); unsubRC?.(); unsubProg?.(); unsubLog?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── WinKeyer wiring ───────────────────────────────────────────────────
const reloadWkPorts = useCallback(() => {
ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {});
}, []);
const reloadWk = useCallback(async () => {
try {
const s: any = await GetWinkeyerSettings();
setWkEnabled(!!s.enabled);
setWkPort(s.port ?? '');
setWkWpm(s.wpm ?? 25);
setWkMacros((s.macros ?? []) as WKMacro[]);
setWkEscClears(s.esc_clears_call !== false);
setWkSendOnType(!!s.send_on_type);
} catch { /* keyer not configured */ }
}, []);
useEffect(() => {
(async () => {
await reloadWk();
const st: any = await GetWinkeyerStatus().catch(() => null);
if (st) setWkStatus(st as WKStatus);
reloadWkPorts();
})();
const unsub = EventsOn('winkeyer:status', (st: WKStatus) => setWkStatus(st));
// Append each echoed char as the keyer transmits it; keep a rolling tail.
const unsubEcho = EventsOn('winkeyer:echo', (ch: string) => setWkSent((s) => (s + ch).slice(-160)));
return () => { unsub?.(); unsubEcho?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Keep a live ref to wkSendMacro so the global key handler calls the latest.
const wkSendMacroRef = useRef<(i: number) => void>(() => {});
// Persist a single WinKeyer field then save (used by the panel's port etc.).
async function saveWk(patch: Record<string, any>) {
try {
const cur: any = await GetWinkeyerSettings();
await SaveWinkeyerSettings({ ...cur, ...patch });
} catch (e: any) { setError(String(e?.message ?? e)); }
}
function wkSetEnabled(on: boolean) {
setWkEnabled(on);
saveWk({ enabled: on });
if (!on) WinkeyerDisconnect().catch(() => {});
}
function wkSelectPort(p: string) { setWkPort(p); saveWk({ port: p }); }
// Resolve macro / CW-text variables from the current entry + active profile.
function resolveCW(text: string): string {
const myCall = (station.callsign || '').toUpperCase();
const his = callsign.trim().toUpperCase();
const cut = (s: string) => (s || '').replace(/0/g, 'T').replace(/9/g, 'N'); // cut numbers
const vars: Record<string, string> = {
MY_CALL: myCall, CALL: his,
STX: cut(rstSent), STRX: cut(rstRcvd), RST_R: cut(rstRcvd),
STXF: rstSent, STRXF: rstRcvd,
NAME: station.operator || '', MY_NAME: station.operator || '', MY_OPCALL: station.operator || myCall,
HIS_NAME: name || '',
GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '',
MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '',
MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '',
CONT_RX: details.srx != null ? String(details.srx) : '', CONT_TX: details.stx != null ? String(details.stx) : '',
};
let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? '');
out = out.replace(/\*/g, myCall).replace(/!/g, his);
// <n> (2..9): repeat the whole resolved string n times.
const rep = out.match(/<([2-9])>/);
if (rep) {
const n = parseInt(rep[1], 10);
const base = out.replace(/<[2-9]>/g, '').replace(/\s+/g, ' ').trim();
out = Array(n).fill(base).join(' ');
}
return out.replace(/\s+/g, ' ').trim();
}
function wkSend(rawText: string) { setWkSent(''); WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e))); }
function wkSendMacro(i: number) { const m = wkMacros[i]; if (m) wkSend(m.text); }
wkSendMacroRef.current = wkSendMacro;
// send-on-type: key the typed chars verbatim (no variable substitution).
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
function wkBackspace() { WinkeyerBackspace().catch(() => {}); }
function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }
// Resolve slot status for any spot we haven't seen yet — debounced so we
// don't hammer the backend at firehose rate. The mode passed to the
// backend is derived from the comment (CW / FT8 / FT4 / SSB...) and the
// band-plan fallback, NOT just digital watering-hole detection — that's
// how CW spots get correctly classified instead of being labelled
// "new-slot" because the lookup key carried mode="".
useEffect(() => {
const t = window.setTimeout(async () => {
const unknown: { call: string; band: string; mode: string }[] = [];
const seen = new Set<string>();
for (const s of spots) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
if (seen.has(k) || spotStatus[k]) continue;
seen.add(k);
unknown.push({ call: s.dx_call, band: s.band ?? '', mode });
}
if (unknown.length === 0) return;
try {
const res = await ClusterSpotStatuses(unknown as any);
setSpotStatus((prev) => {
const next = { ...prev };
for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = {
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
};
}
return next;
});
} catch {}
}, 400);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spots]);
async function save() {
if (!callsign.trim()) { setError('Callsign required'); return; }
setSaving(true); setError('');
try {
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
const rxFreqHz = rxFreqMhz.trim() ? Math.round(parseFloat(rxFreqMhz) * 1_000_000) : undefined;
const now = new Date();
const start = qsoStartedAt ?? now;
const end = (locks.end && qsoEndedAt) ? qsoEndedAt : now;
const payload: any = {
callsign: callsign.trim().toUpperCase(),
qso_date: start.toISOString(),
qso_date_off: end.toISOString(),
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,
state: details.state, cnty: details.cnty, address: details.address,
lat: details.lat, lon: details.lon,
dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz, cont: details.cont || undefined,
qsl_msg: details.qsl_msg, qsl_via: details.qsl_via,
ant_az: details.ant_az, ant_el: details.ant_el, ant_path: details.ant_path,
prop_mode: details.prop_mode,
my_rig: details.my_rig, my_antenna: details.my_antenna,
tx_pwr: details.tx_pwr,
sat_name: details.sat_name, sat_mode: details.sat_mode,
contest_id: details.contest_id,
srx: details.srx, stx: details.stx,
email: details.email,
};
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
await AddQSO(payload);
resetEntry();
await refresh();
} catch (e: any) {
setError(String(e?.message ?? e));
} finally { setSaving(false); }
}
// resetEntry clears the form for the next QSO. Triggered after a
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
// are preserved so backdated batches stay productive.
function resetEntry() {
// Discard any in-progress QSO recording (no-op if it was already saved on
// log, or if the recorder is off).
QSOAudioCancel(); setRecording(false);
setCallsign(''); setComment(''); setNote('');
if (!locks.start) setQsoStartedAt(null);
if (!locks.end) setQsoEndedAt(null);
resetAutoFill();
setWb(null); // clear the Worked-before grid for the just-cleared callsign
setLookupError('');
rstUserEditedRef.current = false;
applyModePreset(mode);
setDetails((d) => ({
...d,
state: '', cnty: '', address: '', lat: undefined, lon: undefined,
dxcc: undefined, cqz: undefined, ituz: undefined, cont: '',
qsl_msg: '', qsl_via: '',
contest_id: '', srx: undefined, stx: undefined,
email: '',
award_refs: '',
}));
}
function resetAutoFill() {
setName(''); setQth(''); setCountry(''); setGrid('');
// NOTE: don't clear `wb` here. It's owned by runWorkedBefore (fast 150 ms
// pass) and the short-callsign guard in scheduleLookup. Clearing it inside
// runLookup blanked the Worked-before table for the whole (possibly slow,
// QRZ-then-cty.dat) lookup → entries flashed in and immediately vanished.
setLookupResult(null);
setDetails((d) => ({
...d,
state: '', cnty: '', address: '',
lat: undefined, lon: undefined,
dxcc: undefined, cqz: undefined, ituz: undefined, cont: '',
qsl_via: '',
email: '',
}));
userEditedRef.current.clear();
lastLookedUpRef.current = '';
lastWbFocusRef.current = '';
setLookupResult(null);
}
async function openEdit(id: number) {
try { setEditingQSO(await GetQSO(id)); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function onModalSave(q: QSO) {
try {
await UpdateQSO(q as any);
setEditingQSO(null);
await refresh();
// The Worked-before grid is loaded separately (per callsign) and isn't
// touched by refresh(), so an edit made from it would leave stale data
// on screen. Reload it when one is shown.
const wbCall = callsign.trim();
if (wb && wbCall.length >= 3) runWorkedBefore(wbCall);
} catch (err: any) { setError(String(err?.message ?? err)); }
}
function onModalDelete(id: number) {
const q = editingQSO; setEditingQSO(null);
if (q) setDeletingQSO(q); else askDelete(id);
}
// Bulk grid actions (right-click menu). Recompute country/zones from
// cty.dat (instant, offline) or re-query QRZ.com, then refresh the views.
async function afterBulkUpdate(n: number, label: string) {
await refresh();
const wbCall = callsign.trim();
if (wb && wbCall.length >= 3) runWorkedBefore(wbCall);
showToast(n > 0 ? `${n} QSO${n > 1 ? 's' : ''} updated ${label}` : `No change ${label}`);
}
async function bulkUpdateFromCty(ids: number[]) {
if (ids.length === 0) return;
try { await afterBulkUpdate(await UpdateQSOsFromCty(ids as any), 'from cty.dat'); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function bulkUpdateFromQRZ(ids: number[]) {
if (ids.length === 0) return;
showToast(`Querying QRZ.com for ${ids.length} QSO${ids.length > 1 ? 's' : ''}`);
try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function bulkUpdateFromClublog(ids: number[]) {
if (ids.length === 0) return;
try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function bulkSendRecording(ids: number[]) {
if (ids.length === 0) return;
showToast(`Sending ${ids.length} recording${ids.length > 1 ? 's' : ''} by e-mail…`);
let ok = 0; const errs: string[] = [];
for (const id of ids) {
try { await SendQSORecordingEmail(id as any); ok++; }
catch (e: any) { errs.push(String(e?.message ?? e)); }
}
if (errs.length) setError(`Recording e-mail: ${ok} sent, ${errs.length} failed — ${errs[0]}`);
else showToast(`${ok} recording${ok > 1 ? 's' : ''} sent`);
}
// Right-click "Send to QRZ.com / Club Log / LoTW": uploads the selected QSOs
// on demand (regardless of their current upload status). Runs in the
// background; qslmgr:done refreshes the grid when finished.
async function bulkSendTo(service: string, ids: number[]) {
if (ids.length === 0) return;
const label = service === 'qrz' ? 'QRZ.com' : service === 'clublog' ? 'Club Log' : service === 'lotw' ? 'LoTW' : service;
showToast(`Uploading ${ids.length} QSO${ids.length > 1 ? 's' : ''} to ${label}`);
try { await UploadQSOsManual(service, ids as any); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that
// matches the current filter, bypassing the on-screen row threshold.
async function exportFilteredADIF() {
try {
const path = await SaveADIFFile();
if (!path) return;
const f = buildActiveFilter();
const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any);
showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Right-click "Export selected to ADIF": only the highlighted rows.
async function exportSelectedADIF(ids: number[]) {
if (ids.length === 0) return;
try {
const path = await SaveADIFFile();
if (!path) return;
const r = await ExportADIFSelected(path, false, ids as any);
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
function askDelete(id: number) {
const q = qsos.find((x) => x.id === id);
if (q) setDeletingQSO(q);
}
async function confirmDelete() {
if (!deletingQSO) return;
try {
await DeleteQSO(deletingQSO.id);
if (selectedId === deletingQSO.id) setSelectedId(null);
setDeletingQSO(null);
await refresh();
} catch (err: any) {
setError(String(err?.message ?? err));
setDeletingQSO(null);
}
}
async function confirmDeleteAll() {
if (deletingAll) return;
setDeletingAll(true);
try {
await DeleteAllQSO();
setSelectedId(null);
setShowDeleteAll(false);
await refresh();
} catch (err: any) { setError(String(err?.message ?? err)); }
finally { setDeletingAll(false); }
}
async function runWorkedBefore(call: string, dxccHint: number = 0) {
setWbBusy(true);
try { setWb(await WorkedBefore(call, dxccHint)); }
catch { setWb(null); }
finally { setWbBusy(false); }
}
async function runLookup(call: string) {
if (call !== lastLookedUpRef.current) resetAutoFill();
setLookupBusy(true);
try {
const r = await LookupCallsign(call);
lastLookedUpRef.current = call;
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
// result is cty.dat-only, it must NEVER overwrite the richer data already
// shown for the same call — so don't downgrade the result badge/image, and
// below we only fill text fields that actually carry a value.
const ctyOnly = r.source === 'cty.dat';
setLookupResult((prev) => (ctyOnly && prev && prev.callsign === r.callsign ? prev : r));
const ue = userEditedRef.current;
if (!ue.has('name') && (r.name ?? '') !== '') setName(r.name ?? '');
if (!ue.has('qth') && (r.qth ?? '') !== '') setQth(r.qth ?? '');
if (!ue.has('grid') && (r.grid ?? '') !== '') setGrid(r.grid ?? '');
// Country/zones are exactly what cty.dat IS authoritative for — set them
// (only skipped if empty, so we never blank a known country).
if (!ue.has('country') && (r.country ?? '') !== '') setCountry(r.country ?? '');
setDetails((d) => ({
...d,
address: d.address || (r.address ?? ''),
state: d.state || (r.state ?? ''),
cnty: d.cnty || (r.cnty ?? ''),
lat: d.lat ?? (r.lat || undefined),
lon: d.lon ?? (r.lon || undefined),
dxcc: d.dxcc ?? (r.dxcc || undefined),
cqz: d.cqz ?? (r.cqz || undefined),
ituz: d.ituz ?? (r.ituz || undefined),
cont: d.cont || (r.cont ?? ''),
email: d.email || (r.email ?? ''),
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
}));
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
} catch (e: any) {
setLookupResult(null);
setLookupError(String(e?.message ?? e));
} finally { setLookupBusy(false); }
}
function scheduleLookup(value: string) {
setLookupError('');
if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current);
if (wbTimerRef.current) window.clearTimeout(wbTimerRef.current);
const call = value.trim().toUpperCase();
if (call.length < 3) {
setLookupResult(null); setWb(null);
if (lastLookedUpRef.current !== '') resetAutoFill();
return;
}
lookupTimerRef.current = window.setTimeout(() => runLookup(call), 400);
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
}
function onCallsignInput(v: string) {
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
// on every status packet. If it matches what's already in the entry,
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
// reload worked-before + the band matrix, making them flicker. Compared
// via the ref so it's correct even from the stale UDP closure.
if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return;
// QSO recorder: a non-empty callsign marks the QSO start (the recorder
// keeps the pre-roll from before this); clearing it discards the take.
// Recording START happens on blur (leaving the callsign field), NOT here —
// you may type a call and work it minutes later. Clearing it cancels.
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); }
const wasEmpty = callsign.trim() === '';
const isEmpty = v.trim() === '';
if (wasEmpty && !isEmpty && !locks.start) {
// First keystroke of a new QSO — freeze the start time so it doesn't
// drift even if the lookup or typing takes 30 seconds. Skip when
// start is locked: the user is back-entering a past QSO and set a
// specific time manually.
setQsoStartedAt(new Date());
} else if (isEmpty && !locks.start) {
// Callsign wiped → user abandoned this QSO; reset the timer.
setQsoStartedAt(null);
}
setCallsign(v);
scheduleLookup(v);
}
function markEdited(field: string) { userEditedRef.current.add(field); }
async function importAdif() {
if (importing) return;
setError('');
try {
const path = await OpenADIFFile();
if (!path) return;
// Stash the path and open the options dialog. The actual import
// is fired from runImport() when the user clicks "Import".
setPendingImportPath(path);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
function exportAdif() {
if (exporting) return;
setShowExportChoice(true); // pick standard vs full first
}
async function runExport(includeAppFields: boolean) {
setShowExportChoice(false);
if (exporting) return;
setError('');
try {
const path = await SaveADIFFile();
if (!path) return;
setExporting(true);
const res = await ExportADIF(path, includeAppFields);
// Reuse the error banner area for a brief success note (4s auto-dismiss).
const msg = `ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`;
setError(msg);
setTimeout(() => setError((e) => e === msg ? '' : e), 4000);
} catch (e: any) {
setError(`ADIF export failed: ${String(e?.message ?? e)}`);
} finally {
setExporting(false);
}
}
async function runImport() {
const path = pendingImportPath;
if (!path || importing) return;
setPendingImportPath(null);
setImporting(true);
setImportProgress({ processed: 0, total: 0 });
setImportResult(null);
setImportErrorsOpen(false);
setImportDupsOpen(false);
try {
const res = await ImportADIF(path, importDupMode, importApplyCty);
setImportResult(res);
await refresh();
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setImporting(false);
setImportProgress(null);
}
}
const menus: Menu[] = useMemo(() => [
{ name: 'file', label: 'File', items: [
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
{ type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
{ type: 'separator' },
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
{ type: 'separator' },
{ type: 'item', label: 'Exit', action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true },
]},
{ name: 'edit', label: 'Edit', items: [
{ type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
{ type: 'item', label: 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
{ type: 'separator' },
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' },
]},
{ name: 'view', label: 'View', items: [
{ type: 'item', label: 'Refresh', action: 'view.refresh', shortcut: 'F5' },
{ type: 'item', label: 'Clear filters', action: 'view.clearfilters' },
]},
{ name: 'tools', label: 'Tools', items: [
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
{ type: 'separator' },
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
{ type: 'separator' },
// Maintenance — bumped here while we only have one entry. Will move
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
]},
{ name: 'help', label: 'Help', items: [
{ type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true },
]},
], [total, selectedId, ctyRefreshing, exporting, wkEnabled, dvkEnabled]);
function handleMenu(action: string) {
switch (action) {
case 'file.import': importAdif(); break;
case 'file.export': exportAdif(); break;
case 'file.deleteall': setShowDeleteAll(true); break;
case 'view.refresh': refresh(); break;
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
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': setQslTabOpen(true); setActiveTab('qsl'); break;
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
case 'tools.dvk': setDvkEnabled((v) => !v); break;
case 'tools.refreshCty': refreshCtyDat(); break;
}
}
async function refreshCtyDat() {
if (ctyRefreshing) return;
setCtyRefreshing(true);
setError('');
try {
const info = await RefreshCtyDat();
// Use the regular error banner area for a brief success note — keeps
// us from pulling in a toast system just for one maintenance action.
setError(`cty.dat refreshed — ${info.entities} entities loaded`);
setTimeout(() => setError((e) => e.startsWith('cty.dat refreshed') ? '' : e), 4000);
} catch (e: any) {
setError(`cty.dat refresh failed: ${String(e?.message ?? e)}`);
} finally {
setCtyRefreshing(false);
}
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName;
const typing = tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA';
// ESC: abort CW when the keyer is live. Whether it ALSO clears the
// callsign depends on the "ESC clears callsign" option; with the keyer
// off it always resets the entry (the classic behaviour).
if (e.key === 'Escape') {
// If a voice message is transmitting, ESC just stops it (keeps entry).
if (dvkActiveRef.current && dvkPlayingRef.current) {
DVKStop();
e.preventDefault();
return;
}
const keyerLive = wkActiveRef.current;
if (keyerLive) WinkeyerStop().catch(() => {});
if (!keyerLive || wkEscClearsRef.current) {
resetEntry();
callsignRef.current?.focus();
}
e.preventDefault();
return;
}
// Function keys (work even while typing — they're not text input):
const fn = /^F([1-9]|1[0-2])$/.exec(e.key);
if (fn) {
const n = parseInt(fn[1], 10); // 1..12
const TABS = ['stats', 'info', 'awards', 'my', 'extended'] as const;
const mod = e.ctrlKey || e.metaKey;
const plain = !mod && !e.altKey;
if (wkActiveRef.current) {
// CW keyer live: plain F1..F12 fire macros; Ctrl+F1..F5 switch the
// detail tab (so the two don't clash). Labels read "Ctrl+F1…".
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
if (plain) { e.preventDefault(); wkSendMacroRef.current(n - 1); return; }
return;
}
if (dvkActiveRef.current) {
// Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs.
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; }
return;
}
// No keyer: plain F1..F5 switch the detail tab (labels read "F1…").
if (plain && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') {
e.preventDefault(); importAdif(); return;
}
if (typing) return;
if (selectedId !== null) {
if (e.key === 'Delete') { e.preventDefault(); askDelete(selectedId); return; }
if (e.key === 'Enter') { e.preventDefault(); openEdit(selectedId); return; }
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId, refresh]);
// ── Entry-field blocks ─────────────────────────────────────────────────
// Each field is defined once here, then composed into either the compact
// single-row strip or the full Log4OM-style columnar layout below. Keeping
// them as shared consts avoids duplicating the (large) per-field JSX +
// handlers across the two layouts.
const callsignBlock = (
<div className="flex flex-col w-36">
<Label className="mb-1 flex items-center gap-2 h-3.5">
Callsign
{lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up</Badge>}
{!lookupBusy && lookupResult && (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider" variant="outline">
<CheckCircle2 className="size-2.5 mr-1" />{lookupResult.source}
</Badge>
)}
{!lookupBusy && !lookupResult && lookupError && (
<Badge variant="destructive" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider">{lookupError}</Badge>
)}
</Label>
<div className="relative">
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap pointer-events-none">
<span className="size-2 rounded-full bg-red-600 animate-pulse" />
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
</span>
)}
<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)}
// Start the QSO recording when leaving the callsign field (the pre-roll
// covers the seconds before). No-op when the recorder is off.
onBlur={() => { if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }}
/>
</div>
</div>
);
const rstTxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST tx</Label>
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
</div>
);
const rstRxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST rx</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
</div>
);
const startBlock = (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-emerald-700">Start UTC <LockBtn k="start" title="start time" /></Label>
<Input
readOnly={!locks.start}
tabIndex={locks.start ? 0 : -1}
value={startFocused ? startInputStr : (qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : (locks.start ? '' : '—'))}
onFocus={() => { setStartInputStr(qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : ''); setStartFocused(true); }}
onBlur={() => {
setStartFocused(false);
if (startInputStr.trim() === '') { if (locks.start) setQsoStartedAt(null); return; }
setQsoStartedAt(parseHMSUTC(startInputStr, qsoStartedAt ?? new Date()));
}}
onChange={(e) => setStartInputStr(e.target.value)}
placeholder="HH:MM:SS"
className={cn('font-mono', locks.start ? '' : 'bg-muted/40 cursor-default')}
/>
</div>
);
const endBlock = (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-rose-700">End UTC <LockBtn k="end" title="end time" /></Label>
<Input
readOnly={!locks.end}
tabIndex={locks.end ? 0 : -1}
value={endFocused ? endInputStr : (locks.end ? (qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : '') : (qsoStartedAt ? utcNow.slice(11) : '—'))}
onFocus={() => { setEndInputStr(qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : ''); setEndFocused(true); }}
onBlur={() => {
setEndFocused(false);
if (endInputStr.trim() === '') { if (locks.end) setQsoEndedAt(null); return; }
setQsoEndedAt(parseHMSUTC(endInputStr, qsoEndedAt ?? new Date()));
}}
onChange={(e) => setEndInputStr(e.target.value)}
placeholder="HH:MM:SS"
className={cn('font-mono', locks.end ? '' : 'bg-muted/40 cursor-default')}
/>
</div>
);
const nameBlock = (
<div className="flex flex-col flex-1 min-w-[110px]"><Label className="mb-1 h-3.5">Name</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</div>
);
const qthBlock = (
<div className="flex flex-col flex-1 min-w-[90px]"><Label className="mb-1 h-3.5">QTH</Label>
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
</div>
);
const gridBlock = (
<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>
);
// Compact-strip Country (stacked label) + a narrow Comment.
const countryBlockSm = (
<div className="flex flex-col w-36">
<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>
);
const commentSm = (
<div className="flex flex-col w-40"><Label className="mb-1 h-3.5">Comment</Label>
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
</div>
);
// Inline-label variants (label to the LEFT of the control, Log4OM-style) —
// used in the full layout to save vertical height.
const bandRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label>
<div className="flex-1 min-w-0">
<Select value={band} onValueChange={onBandUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
);
const modeRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">Mode <LockBtn k="mode" title="mode" /></Label>
<div className="flex-1 min-w-0">
<Select value={mode} onValueChange={onModeUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
);
const countryRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 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 mr-0.5"
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
)}
</Label>
<div className="flex-1 min-w-0">
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
</div>
</div>
);
const cqBlock = (
<div className="flex flex-col w-10"><Label className="mb-1 h-3.5 text-[10px]">CQ</Label>
<Input inputMode="numeric" maxLength={2} className="font-mono text-center px-0.5 text-xs h-7" value={details.cqz ?? ''} placeholder="—"
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
</div>
);
const ituBlock = (
<div className="flex flex-col w-10"><Label className="mb-1 h-3.5 text-[10px]">ITU</Label>
<Input inputMode="numeric" maxLength={2} className="font-mono text-center px-0.5 text-xs h-7" value={details.ituz ?? ''} placeholder="—"
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
</div>
);
const freqBlock = (
<div className="flex flex-col w-32">
<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>
);
const rxFreqBlock = (
<div className="flex flex-col w-32">
<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>
);
const bandRxBlock = (
<div className="flex flex-col w-28"><Label className="mb-1 h-3.5">RX Band</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>
);
// Single-line Comment/Note for the full layout (stacked in the right
// column). No flex-1 so they stay one row tall.
const commentLine = (
<div className="flex flex-col"><Label className="mb-1 h-3.5">Comment</Label>
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
</div>
);
const noteLine = (
<div className="flex flex-col"><Label className="mb-1 h-3.5">Note</Label>
<Input value={note} onChange={(e) => setNote(e.target.value)} />
</div>
);
const logButtons = (
<div className="flex flex-col">
<Label className="mb-1 h-3.5">&nbsp;</Label>
<div className="flex gap-2">
{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 type="button" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8"
title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
<Eraser className="size-3.5" />
Clear
</Button>
<Button onClick={save} disabled={saving} className="h-8">
<Send className="size-3.5" />
{saving ? '…' : 'Log QSO'}
</Button>
</div>
</div>
);
// Compact strip: Log only — the Spot dialog would be clipped by the tiny
// always-on-top window, so it's reachable only in normal mode.
const logButtonCompact = (
<div className="flex flex-col">
<Label className="mb-1 h-3.5">&nbsp;</Label>
<Button onClick={save} disabled={saving} className="h-8">
<Send className="size-3.5" />
{saving ? '…' : 'Log QSO'}
</Button>
</div>
);
return (
<div className="flex flex-col h-screen bg-background">
<ShutdownProgress />
{/* ===== TOPBAR ===== */}
{compact ? (
// Minimal compact topbar — brand + freq + toggle. Saves vertical space
// so the single-row entry strip fits in a ~140px tall window.
<header className="flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
<div className="flex items-center gap-1.5">
<div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
<span className="font-bold text-xs tracking-tight">OpsLog</span>
</div>
<div className="flex items-baseline gap-1.5 font-mono ml-2">
<span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
<span className="text-[9px] text-muted-foreground">MHz</span>
<Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge>
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono text-[9px] py-0" variant="outline">{mode}</Badge>
</div>
<span className="ml-2 font-mono text-[10px] text-muted-foreground">{utcNow}<span className="text-[9px]">Z</span></span>
<div className="flex-1" />
{station.callsign && (
<span className="font-mono text-[11px] font-bold text-primary mr-2">{station.callsign}</span>
)}
<Button variant="outline" size="icon" className="size-6" onClick={toggleCompact} title="Exit compact mode">
<Maximize2 className="size-3" />
</Button>
</header>
) : (
<header className="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
<span className="font-bold text-[15px] tracking-tight">OpsLog</span>
<span className="text-[11px] text-muted-foreground">v0.1</span>
</div>
<Menubar menus={menus} onAction={handleMenu} />
<div className="flex items-center justify-center gap-2 font-mono">
<div className="flex flex-col items-end leading-none">
<span className="text-[22px] font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
{catState.split && rxFreqMhz && (
<span className="text-[10px] text-muted-foreground mt-0.5">
<span className="text-rose-600 font-semibold mr-1">RX</span>
{fmtFreqDots(rxFreqMhz)}
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground uppercase">MHz</span>
{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" />
{/* 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. */}
{(() => {
const p = pathBetween(station.my_grid, grid);
const disabled = !p;
const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)));
return (
<div className="inline-flex items-center rounded-full border border-sky-300 bg-sky-100 overflow-hidden text-[11px] font-mono font-semibold">
<button
type="button"
disabled={disabled}
onClick={() => p && goto(p.bearingShort)}
title={p
? `Rotate short-path · ${Math.round(p.distanceShort).toLocaleString()} km`
: (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
disabled
? 'text-muted-foreground/60 cursor-not-allowed'
: 'text-sky-800 hover:bg-sky-200 cursor-pointer',
)}
>
<Compass className="size-3" />
{p ? `${Math.round(p.bearingShort)}°` : '—°'}
</button>
<button
type="button"
disabled={disabled}
onClick={() => p && goto(p.bearingLong)}
title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
className={cn(
'px-1.5 py-0.5 border-l border-sky-300 text-[10px] transition-colors',
disabled
? 'text-muted-foreground/60 cursor-not-allowed'
: 'text-sky-800 hover:bg-sky-200 cursor-pointer',
)}
>
LP {p ? `${Math.round(p.bearingLong)}°` : '—'}
</button>
<button
type="button"
onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))}
title="Stop rotation"
className="px-1.5 py-0.5 border-l border-sky-300 text-rose-700 hover:bg-rose-100 hover:text-rose-800 cursor-pointer transition-colors"
>
<Square className="size-2.5 fill-current" />
</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">
<Clock className="size-3" />
{utcNow}<span className="text-[10px]">UTC</span>
</div>
<div className="flex items-center gap-3">
{station.callsign ? (
<button
onClick={() => { setSettingsSection('profiles'); setShowSettings(true); }}
className="flex items-center gap-1.5 bg-accent border border-accent-foreground/20 text-accent-foreground px-3 py-1 rounded-full font-mono text-xs font-bold hover:bg-accent/80 transition-colors"
title="Click to switch / edit profiles"
>
<Antenna className="size-3" />
{station.callsign}
{station.my_grid && (
<span className="bg-white/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
)}
</button>
) : (
<Button variant="outline" size="sm" onClick={() => setShowSettings(true)}>
<Settings className="size-3.5" /> Set station
</Button>
)}
<Button
variant={showBandMap ? 'default' : 'outline'}
size="sm"
onClick={() => setShowBandMap((v) => !v)}
title="Toggle band map (visible across all tabs)"
className="h-8"
>
Band map
</Button>
<Button
variant="outline"
size="icon"
onClick={toggleCompact}
title="Compact mode (small always-on-top window)"
className="size-8"
>
<Minimize2 className="size-3.5" />
</Button>
</div>
</header>
)}
{/* QRZ profile photo lightbox — full size, in-app. Click anywhere or
press Esc to close; click the image itself doesn't close. */}
{photoModal && (
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-stone-900/70 backdrop-blur-sm p-6 animate-in fade-in"
onClick={() => setPhotoModal(null)}
>
<button
type="button"
className="absolute top-4 right-4 rounded-md bg-white/10 hover:bg-white/20 text-white p-1.5"
onClick={() => setPhotoModal(null)}
title="Close (Esc)"
>
<X className="size-5" />
</button>
<img
src={photoModal}
alt="profile full size"
className="max-h-full max-w-full object-contain rounded-lg shadow-2xl"
referrerPolicy="no-referrer"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
{/* Transient toasts (bottom-right). Errors stack on top of the green
success toast; both auto-dismiss. */}
{migratedBanner && (
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-[110] flex items-start gap-3 rounded-lg border border-emerald-400 bg-emerald-50 text-emerald-900 px-4 py-3 text-sm shadow-xl max-w-lg animate-in fade-in slide-in-from-top-2">
<span className="flex-1">
<strong>Migration complete.</strong> Your data has been copied to the data folder next to OpsLog.exe.
Please <strong>restart OpsLog</strong> to use the new location.
</span>
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setMigratedBanner(false)}>×</button>
</div>
)}
{(error || toast) && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2 max-w-md">
{error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 text-destructive px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<pre className="flex-1 font-sans whitespace-pre-wrap m-0 leading-snug">{error}</pre>
<button className="ml-1 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3.5" /></button>
</div>
)}
{toast && (
<div className="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>
)}
</div>
)}
{/* "You have been spotted" banner — shows when our own callsign appears
in a cluster spot (Log4OM-style). Floated as a bottom-center overlay
so it never shifts the layout (push-down / spring-back) and never
covers the entry fields; auto-hides 3s after the last self-spot. */}
{!compact && selfSpot && (
<div className="fixed bottom-16 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-100 text-amber-900 px-3.5 py-2 text-xs shadow-lg animate-in fade-in slide-in-from-bottom-2">
<RadioTower className="size-3.5 shrink-0" />
<span>
You've been spotted by <strong className="font-mono">{selfSpot.spotter || '?'}</strong>
{' '}on <strong className="font-mono">{selfSpot.freqKHz?.toFixed(1)} kHz</strong>
{selfSpot.band ? ` (${selfSpot.band})` : ''}
{selfSpot.comment ? <span className="text-amber-800"> — {selfSpot.comment}</span> : null}
</span>
<div className="flex-1" />
<button className="text-amber-700 hover:text-amber-900" title="Dismiss" onClick={() => setSelfSpot(null)}>
<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={cn('bg-card shadow-sm border-border',
compact
? 'flex gap-2 items-end flex-nowrap px-3 py-2 border-b shrink-0 overflow-hidden'
: 'flex flex-col gap-2 px-3 py-2 flex-1 min-w-[560px] max-w-[760px] border rounded-lg')}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
e.preventDefault();
save();
}
// ESC is handled globally (stop CW + optional callsign reset).
}}
>
{compact ? (
/* Compact strip: Call + RST + Name/QTH/Country + Comment on one
row. Band/Mode/Freq are omitted — they're shown in the compact
top bar just above. */
<>
{callsignBlock}
{rstTxBlock}
{rstRxBlock}
{nameBlock}
{qthBlock}
{countryBlockSm}
{commentSm}
<div className="ml-auto">{logButtonCompact}</div>
</>
) : (
/* Full Log4OM-style columnar layout. */
<>
{/* Row 1: Callsign + RST + CQ/ITU zones, then Start/End at right. */}
<div className="flex gap-2 items-end">
{callsignBlock}
{rstTxBlock}
{rstRxBlock}
{cqBlock}
{ituBlock}
<div className="ml-auto flex gap-2">
{startBlock}
{endBlock}
</div>
</div>
{/* Row 2: wide Name + QTH + Grid across the full width. */}
<div className="flex gap-2 items-end">
{nameBlock}
{qthBlock}
{gridBlock}
</div>
{/* Row 3: tight left detail column (Band/Mode/Country) and
single-line Comment/Note on the right. */}
<div className="flex gap-4 items-start">
<div className="flex flex-col gap-1.5 w-[300px] shrink-0">
{bandRow}
{modeRow}
{countryRow}
</div>
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
{commentLine}
{noteLine}
</div>
</div>
{/* Bottom: TX freq, RX freq, RX band — plus the action buttons. */}
<div className="flex gap-2 items-end">
{freqBlock}
{rxFreqBlock}
{bandRxBlock}
<div className="ml-auto">{logButtons}</div>
</div>
</>
)}
</section>
{/* 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}
tab={detailTab}
onTab={setDetailTab}
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
/>
</div>
)}
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */}
{!compact && (wkEnabled || dvkEnabled || lookupResult?.image_url) && (
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
{dvkEnabled && (
<div className="flex-1 min-w-0 min-h-0">
<DvkPanel
messages={dvkMsgs}
status={dvkStat}
onPlay={dvkPlay}
onStop={() => DVKStop()}
onClose={() => setDvkEnabled(false)}
/>
</div>
)}
{wkEnabled && (
<div className="flex-1 min-w-0 min-h-0">
<WinkeyerPanel
status={wkStatus}
ports={wkPorts}
port={wkPort}
wpm={wkWpm}
macros={wkMacros}
sent={wkSent}
onSelectPort={wkSelectPort}
onRefreshPorts={reloadWkPorts}
onConnect={() => WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))}
onDisconnect={() => WinkeyerDisconnect().catch(() => {})}
onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
onSend={wkSend}
onSendMacro={wkSendMacro}
onStop={() => WinkeyerStop().catch(() => {})}
onClose={() => wkSetEnabled(false)}
sendOnType={wkSendOnType}
onToggleSendOnType={wkToggleSendOnType}
onSendRaw={wkSendRaw}
onBackspace={wkBackspace}
/>
</div>
)}
{/* QRZ photo: when the keyer is open it sits to its right at natural
(capped) width, shrinking the keyer panel rather than hiding it. */}
{lookupResult?.image_url && (
<div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')}>
<button
type="button"
onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)}
className="rounded-lg border border-border overflow-hidden hover:border-primary/60 transition-colors bg-muted/20"
title="Click to view full size"
>
<img
src={lookupResult.image_url}
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'; }}
/>
</button>
</div>
)}
</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 ? (bandMapSide === 'left' ? 'grid-cols-[260px_1fr]' : '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">
<TabsList className="px-3 shrink-0">
<TabsTrigger value="main">Main</TabsTrigger>
<TabsTrigger value="recent">
Recent QSOs
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{qsos.length}</Badge>
</TabsTrigger>
<TabsTrigger value="cluster">Cluster</TabsTrigger>
<TabsTrigger value="worked">
Worked before
{wb && wb.count > 0 && (
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{wb.count}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="awards">Awards</TabsTrigger>
{/* Not a tab — QRZ blocks embedding, so this opens the call's
QRZ.com page in the system browser. Styled like a trigger. */}
<button
type="button"
disabled={!callsign.trim()}
title={callsign.trim() ? `Open ${callsign.trim().toUpperCase()} on QRZ.com` : 'Enter a callsign first'}
onClick={() => {
const c = callsign.trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e) => setError(String(e?.message ?? e)));
}}
className="inline-flex items-center justify-center gap-1 whitespace-nowrap px-3 py-1.5 text-xs font-medium text-muted-foreground border-b-2 border-transparent transition-all hover:text-foreground disabled:pointer-events-none disabled:opacity-50 -mb-px"
>
QRZ.com
</button>
{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">
<div className="flex gap-2 p-2.5 border-b border-border/60">
<Input
className="flex-1"
placeholder="Search callsign…"
value={filterCallsign}
onChange={(e) => setFilterCallsign(e.target.value)}
/>
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw className="size-3.5" /> Refresh
</Button>
</div>
{importResult && (
<div className={cn(
'mx-2.5 mt-2 px-3 py-2 rounded-md text-xs border flex flex-col gap-1.5',
importResult.errors && importResult.errors.length > 0
? 'bg-amber-50 border-amber-300 text-amber-800'
: 'bg-emerald-50 border-emerald-300 text-emerald-800',
)}>
<div className="flex items-center gap-3 flex-wrap">
<strong>Import complete.</strong>
<Badge variant="outline" className="bg-white/60 font-mono text-emerald-700 border-emerald-300">{importResult.imported} imported</Badge>
{importResult.updated > 0 && (
<Badge variant="outline" className="bg-white/60 font-mono text-violet-700 border-violet-300">{importResult.updated} updated</Badge>
)}
{importResult.duplicates > 0 && (
<Badge variant="outline" className="bg-white/60 font-mono text-sky-700 border-sky-300">{importResult.duplicates} duplicates</Badge>
)}
<Badge variant="outline" className="bg-white/60 font-mono text-amber-700 border-amber-300">{importResult.skipped} skipped</Badge>
<Badge variant="outline" className="bg-white/60 font-mono">{importResult.total} total</Badge>
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
<button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
{importDupsOpen ? 'Hide' : 'Show'} duplicates
{importResult.duplicates > importResult.duplicate_samples.length
? ` (first ${importResult.duplicate_samples.length} of ${importResult.duplicates})`
: ''}
</button>
)}
{importResult.errors && importResult.errors.length > 0 && (
<button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}>
{importErrorsOpen ? 'Hide' : 'Show'} {importResult.errors.length} error{importResult.errors.length > 1 ? 's' : ''}
</button>
)}
<button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
</div>
{importDupsOpen && importResult.duplicate_samples && (
<ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
{importResult.duplicate_samples.map((d, i) => <li key={i}>{d}</li>)}
</ul>
)}
{importErrorsOpen && importResult.errors && (
<ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
{importResult.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
)}
</div>
)}
<RecentQSOsGrid
rows={qsos as any}
total={total}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ}
onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo}
onSendRecording={bulkSendRecording}
onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF}
onRowSelected={(id) => setSelectedId(id)}
/>
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
<div className="flex items-center gap-3">
<Button
variant={(activeFilter.conditions?.length || filterCallsign) ? 'default' : 'outline'}
size="sm"
className="h-7 px-2 text-[11px] gap-1"
onClick={() => setFilterOpen(true)}
title="Build an advanced filter"
>
<SlidersHorizontal className="size-3.5" /> Filters
{activeFilter.conditions?.length ? (
<span className="ml-0.5 rounded-full bg-primary-foreground/20 px-1.5 font-mono">{activeFilter.conditions.length}</span>
) : null}
</Button>
{(activeFilter.conditions?.length || filterCallsign) ? (
<button
className="text-muted-foreground hover:text-foreground underline decoration-dotted"
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
>clear</button>
) : null}
<span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
</span>
</div>
<div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && (
<span className="text-amber-700">Limit reached raise Max to see more.</span>
)}
<Label className="text-[11px] text-muted-foreground">Max</Label>
<Input
type="number"
min={1}
step={100}
className="w-24 h-7 font-mono text-xs"
value={qsoLimit}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n) && n > 0) setQsoLimit(Math.floor(n));
}}
/>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => setQsoLimit(Math.max(total, 1))}
title="Load the entire log"
disabled={total === 0}
>
All ({total})
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="cluster" className="mt-0 flex flex-row min-h-0 flex-1">
<div className="flex flex-col min-h-0 flex-1">
{/* Row 1: actions + per-server pills */}
<div className="flex items-center gap-2 px-2.5 pt-2.5 flex-wrap">
<Button
variant="outline" size="sm"
onClick={async () => { await ConnectAllClusters().catch((e) => setError(String(e?.message ?? e))); await reloadClusterMeta(); }}
>
Connect all
</Button>
<Button
variant="outline" size="sm"
onClick={async () => { await DisconnectAllClusters().catch(() => {}); await reloadClusterMeta(); }}
>
Disconnect all
</Button>
{clusterServerStatuses.length === 0 && (
<span className="text-xs text-muted-foreground italic">
No active sessions configure clusters in Settings DX Cluster.
</span>
)}
{clusterServerStatuses.map((s) => {
const isMaster = clusterServers
.filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
return (
<span
key={s.server_id}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border',
s.state === 'connected' ? 'bg-emerald-100 text-emerald-800 border-emerald-300' :
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' :
s.state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' :
'bg-muted text-muted-foreground border-border',
)}
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
>
{isMaster && <span className="text-amber-600" title="Master (commands go here)"></span>}
{s.name}
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span>
</span>
);
})}
<div className="flex-1" />
<Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
</div>
{/* Row 2: filters */}
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border/60 flex-wrap text-xs">
<Input
className="w-32 h-7 text-xs font-mono uppercase"
placeholder="Search call…"
value={clusterSearch}
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
/>
<span className="text-muted-foreground">Bands:</span>
{bands.map((b) => {
const on = clusterBands.has(b);
return (
<button
key={b}
type="button"
onClick={() => setClusterBands((s) => {
const n = new Set(s);
if (n.has(b)) n.delete(b); else n.add(b);
return n;
})}
className={cn(
'px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors',
on
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
)}
>
{b}
</button>
);
})}
{clusterBands.size > 0 && (
<button
type="button"
onClick={() => setClusterBands(new Set())}
className="text-[10px] text-muted-foreground hover:text-foreground underline"
title="Clear band filter"
>
clear
</button>
)}
<div className="w-px h-4 bg-border mx-1" />
<button
type="button"
onClick={() => setClusterLockBand((v) => !v)}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
clusterLockBand
? 'bg-amber-100 text-amber-800 border-amber-300'
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
)}
title="Only show spots on the entry strip's current band"
>
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
Lock band ({band})
</button>
<button
type="button"
onClick={() => setClusterLockMode((v) => !v)}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
clusterLockMode
? 'bg-amber-100 text-amber-800 border-amber-300'
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
)}
title="Only show spots whose mode matches the entry strip"
>
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
Lock mode ({mode})
</button>
<div className="w-px h-4 bg-border mx-1" />
<span className="text-muted-foreground">Status:</span>
{([
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
]).map((s) => {
const on = clusterStatusFilter.has(s.k);
return (
<button
key={s.k}
type="button"
onClick={() => setClusterStatusFilter((cur) => {
const n = new Set(cur);
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
return n;
})}
className={cn(
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
)}
>
{s.label}
</button>
);
})}
<div className="w-px h-4 bg-border mx-1" />
<span className="text-muted-foreground">Mode:</span>
{([
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
]).map((s) => {
const on = clusterModeFilter.has(s.k);
return (
<button
key={s.k}
type="button"
onClick={() => setClusterModeFilter((cur) => {
const n = new Set(cur);
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
return n;
})}
className={cn(
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
)}
>
{s.label}
</button>
);
})}
<div className="flex-1" />
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
Group
</label>
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
<SelectTrigger className="w-32 h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All sources</SelectItem>
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{(() => {
// Apply every filter. `bandsActive` is the band set the
// user clicked, OR the entry's locked band when Lock band
// is on. Mode lock compares the spot's inferred mode to
// the entry's current one.
const bandsActive = clusterLockBand
? new Set([band])
: clusterBands;
const search = clusterSearch.trim().toUpperCase();
let list = spots.filter((s) => {
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
if (search && !s.dx_call.includes(search)) return false;
if (clusterLockMode) {
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
if (spotMode && mode && spotMode !== mode) return false;
}
if (clusterModeFilter.size > 0) {
const cat = spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz));
if (!cat || !clusterModeFilter.has(cat)) return false;
}
if (clusterStatusFilter.size > 0) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const st = spotStatus[k]?.status || '';
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
}
return true;
});
let rendered = list as (ClusterSpot & { repeats?: number })[];
if (clusterGroup) {
const seen = new Map<string, ClusterSpot & { repeats: number }>();
for (const s of list) {
const e = seen.get(s.dx_call);
if (e) { e.repeats++; }
else seen.set(s.dx_call, { ...s, repeats: 1 });
}
rendered = Array.from(seen.values());
}
if (rendered.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
<div className="text-sm font-semibold text-foreground/70">
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
</div>
<div className="text-xs">
{clusterServerStatuses.some((s) => s.state === 'connected')
? 'Spots will appear as the cluster sends them.'
: 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
</div>
</div>
);
}
return (
<ClusterGrid
rows={rendered as any}
spotStatus={spotStatus}
onSpotClick={(s) => {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
SetCATFrequency(s.freq_hz).catch(() => {});
if (m) SetCATMode(m).catch(() => {});
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
if (m) setMode(m);
}
onCallsignInput(s.dx_call);
}}
/>
);
})()}
{/* Command input — sends to the master server. */}
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap"> master</span>
<Input
className="font-mono text-xs h-8"
placeholder='sh/dx 30, set/needsdxcc, …'
value={clusterCmd}
onChange={(e) => setClusterCmd(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && clusterCmd.trim()) {
SendClusterCommand(clusterCmd.trim())
.then(() => setClusterCmd(''))
.catch((err) => setError(String(err?.message ?? err)));
}
}}
/>
<Button
variant="outline" size="sm"
onClick={() => {
if (!clusterCmd.trim()) return;
SendClusterCommand(clusterCmd.trim())
.then(() => setClusterCmd(''))
.catch((err) => setError(String(err?.message ?? err)));
}}
disabled={!clusterCmd.trim()}
>
Send
</Button>
</div>
</div>{/* /left column */}
{/* BandMap moved to a global side panel below — toggle is
now in the topbar, visible on every tab. */}
</TabsContent>
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} />
</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 onEditQSO={openEdit} />
</TabsContent>
)}
<TabsContent value="main" className="flex-1 min-h-0 p-0">
<MainMap
fromGrid={station.my_grid}
toGrid={grid}
fromLabel={station.callsign}
toLabel={callsign}
/>
</TabsContent>
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
<AwardsPanel />
</TabsContent>
</Tabs>
</section>
{showBandMap && (
<div className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden', bandMapSide === 'left' && 'order-first')}>
<BandMap
side={bandMapSide}
onToggleSide={toggleBandMapSide}
band={band}
spots={spots.filter((s) => s.band === band)}
spotStatus={spotStatus}
currentFreqHz={freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={(s) => {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
SetCATFrequency(s.freq_hz).catch(() => {});
if (m) SetCATMode(m).catch(() => {});
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
if (m) setMode(m);
}
onCallsignInput(s.dx_call);
}}
onClose={() => setShowBandMap(false)}
/>
</div>
)}
</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)} countries={countries} />
)}
<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}
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
/>
)}
{deletingQSO && (
<ConfirmDialog
title="Delete QSO?"
message={`This will permanently delete the QSO with ${deletingQSO.callsign} on ${fmtDateUTC(deletingQSO.qso_date)} (${deletingQSO.band} ${deletingQSO.mode}). This cannot be undone.`}
confirmLabel="Delete"
danger
onConfirm={confirmDelete}
onCancel={() => setDeletingQSO(null)}
/>
)}
{showDeleteAll && (
<ConfirmDialog
title="Delete ALL QSOs?"
message={`This will permanently wipe every QSO from the logbook (${total.toLocaleString('en-US')} contacts). This cannot be undone. Consider exporting an ADIF backup first.`}
confirmLabel={deletingAll ? 'Deleting…' : `Delete ${total.toLocaleString('en-US')} QSOs`}
confirmPhrase="DELETE ALL"
danger
onConfirm={confirmDeleteAll}
onCancel={() => { if (!deletingAll) setShowDeleteAll(false); }}
/>
)}
<FilterBuilder
open={filterOpen}
initial={activeFilter}
onApply={(f) => { setActiveFilter(f); setFilterOpen(false); }}
onClose={() => setFilterOpen(false)}
/>
{showExportChoice && (
<Dialog open onOpenChange={(o) => { if (!o) setShowExportChoice(false); }}>
<DialogContent className="max-w-lg px-6">
<DialogHeader className="px-2">
<DialogTitle>Export ADIF</DialogTitle>
<DialogDescription>
Choose which fields to include in the export.
</DialogDescription>
</DialogHeader>
<div className="px-2 py-1 space-y-2.5">
<button
type="button"
onClick={() => runExport(false)}
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
>
<div className="font-semibold text-sm">Standard ADIF</div>
<div className="text-xs text-muted-foreground mt-0.5">
Only standard ADIF-defined fields portable to other loggers (Log4OM, N1MM, LoTW).
Application-specific <span className="font-mono">APP_*</span> tags are stripped.
</div>
</button>
<button
type="button"
onClick={() => runExport(true)}
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
>
<div className="font-semibold text-sm">Full (OpsLog round-trip)</div>
<div className="text-xs text-muted-foreground mt-0.5">
Every field including OpsLog/application-specific <span className="font-mono">APP_*</span> tags
for a lossless backup you'll re-import into OpsLog.
</div>
</button>
</div>
<DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setShowExportChoice(false)}>Cancel</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{pendingImportPath && (
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
<DialogContent className="max-w-lg px-6">
<DialogHeader className="px-2">
<DialogTitle>Import ADIF</DialogTitle>
<DialogDescription className="text-xs break-all">
{pendingImportPath}
</DialogDescription>
</DialogHeader>
<div className="py-2 px-2 space-y-2">
<div className="text-xs text-muted-foreground">
Duplicate = same <span className="font-medium text-foreground">callsign + UTC minute + band + mode</span> as a QSO already in the log.
</div>
{([
{ id: 'skip', title: 'Skip duplicates', desc: 'Leave existing QSOs untouched, only add new ones. Safe default.' },
{ id: 'update', title: 'Update duplicates', desc: 'Refresh existing QSOs with this file merges its non-empty fields (QSL/LoTW/eQSL/QRZ statuses & dates, etc.) onto the matching QSO. Use this to re-sync from Log4OM or LoTW. Fields the file omits are kept.' },
{ id: 'all', title: 'Import everything', desc: 'Insert every record, duplicates included. For intentionally merging two overlapping logs.' },
] as const).map((o) => (
<button
key={o.id}
type="button"
onClick={() => setImportDupMode(o.id)}
className={`w-full text-left rounded-lg border p-2.5 transition-colors ${
importDupMode === o.id
? 'border-primary bg-accent/40 ring-1 ring-primary/40'
: 'border-border hover:border-primary/60 hover:bg-accent/20'
}`}
>
<div className="flex items-center gap-2">
<span className={`size-3.5 rounded-full border flex items-center justify-center ${importDupMode === o.id ? 'border-primary' : 'border-muted-foreground/50'}`}>
{importDupMode === o.id && <span className="size-1.5 rounded-full bg-primary" />}
</span>
<span className="font-semibold text-sm">{o.title}</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5 pl-5.5">{o.desc}</div>
</button>
))}
<label className="flex items-start gap-2 text-sm cursor-pointer pt-1 border-t mt-1">
<Checkbox
checked={importApplyCty}
onCheckedChange={(c) => setImportApplyCty(!!c)}
className="mt-0.5"
/>
<span>
Fix country &amp; zones (cty.dat + ClubLog)
<span className="block text-xs text-muted-foreground mt-0.5">
Recompute Country, DXCC &amp; CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). If ClubLog exceptions are enabled, its date-ranged DXpedition overrides are applied on top (per QSO date). Everything else in the ADIF is kept as-is.
</span>
</span>
</label>
</div>
<DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
<Button onClick={runImport}>Import</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{importing && (
<Dialog open>
<DialogContent className="max-w-sm px-6" hideClose>
<DialogHeader className="px-2">
<DialogTitle>Importing ADIF…</DialogTitle>
</DialogHeader>
<div className="px-2 pb-4 space-y-2">
{(() => {
const done = importProgress?.processed ?? 0;
const tot = importProgress?.total ?? 0;
const pct = tot > 0 ? Math.min(100, Math.round((done / tot) * 100)) : 0;
return (
<>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full bg-primary rounded-full transition-[width] duration-150', tot === 0 && 'w-1/3 animate-pulse')}
style={tot > 0 ? { width: `${pct}%` } : undefined}
/>
</div>
<div className="text-xs text-muted-foreground text-center font-mono">
{tot > 0
? `${done.toLocaleString()} / ${tot.toLocaleString()} records · ${pct}%`
: `${done.toLocaleString()} records…`}
</div>
</>
);
})()}
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}