Files
OpsLog/frontend/src/App.tsx
T

4647 lines
236 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, Bell, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
} from 'lucide-react';
import {
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
ContestDupe,
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus, CheckForUpdate,
WorkedBefore,
SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading,
GetDBConnectionInfo, GetLogbookRevision,
GetUltrabeamStatus, SetUltrabeamDirection,
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
GetCATSettings,
OperatingDefaultForBand,
LogUDPLoggedADIF,
ListCountries,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs,
GetUIPref,
ReportLiveActivity,
AwardRefsForQSOs,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
import { EventsOn, BrowserOpenURL } 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 { APP_VERSION, APP_AUTHOR } from '@/version';
import { QSLManagerPanel } from '@/components/QSLManagerModal';
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
import { WorldMap, LocatorMap } from '@/components/MainMap';
import { FlexPanel } from '@/components/FlexPanel';
import { IcomPanel } from '@/components/IcomPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
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 { NetControlPanel } from '@/components/NetControlPanel';
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
import { DuplicatesModal } from '@/components/DuplicatesModal';
import { useI18n } from '@/lib/i18n';
import { AlertsModal } from '@/components/AlertsModal';
import { BulkEditModal } from '@/components/BulkEditModal';
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { writeUiPref } from '@/lib/uiPref';
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, pathBetweenLatLon, gridToLatLon, latLonToGrid } 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 (phone only). Mirrors recordableMode() in
// app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex,
// so neither is recorded (no REC badge / timer for them).
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);
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_string: '', stx_string: '',
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';
}
// estimateCwMs roughly estimates how long the resolved text takes to send in CW
// at wpm: ~10 dot-units per character + ~4 extra per word gap, 1 unit = 1200/wpm
// ms (PARIS standard). Used to cap waits on the keyer's "busy" status, which can
// lag by tens of seconds over a remote/serial-over-IP link.
function estimateCwMs(resolved: string, wpm: number): number {
const w = Math.max(5, wpm || 25);
const chars = resolved.replace(/\s/g, '').length;
const spaces = (resolved.match(/\s/g) || []).length;
return ((chars * 10 + spaces * 4) * 1200) / w;
}
// exchangeFields splits a contest exchange the operator typed as free text into
// the ADIF numeric field (SRX/STX, when it's all digits) or the string field
// (SRX_STRING/STX_STRING, when it's alphanumeric like a section/zone).
function exchangeFields(raw?: string): { num?: number; str: string } {
const t = (raw || '').trim();
if (t === '') return { str: '' };
return /^\d+$/.test(t) ? { num: parseInt(t, 10), str: '' } : { str: t };
}
// 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() {
const { t } = useI18n();
// === 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 };
if (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 (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);
}
} else {
// Locking (manual / deferred entry) → pre-fill with today's date + the
// current UTC time so the fields aren't empty; the operator just adjusts.
const now = new Date();
if (k === 'start') setQsoStartedAt((d) => d ?? now);
else if (k === 'end') setQsoEndedAt((d) => d ?? now);
}
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-warning hover:text-warning' : '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('');
// Contest mode: a picked contest + exchange config; persisted so a running
// contest (serial counter included) survives a restart. `rcv` is the received
// exchange for the current QSO.
const [contest, setContest] = useState<ContestSession>(CONTEST_DEFAULT);
const [rcv, setRcv] = useState('');
const [contestDupe, setContestDupe] = useState(false);
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);
// Configured CAT backend ('icom' USB vs 'icom-net'): the live catState.backend
// is "icom" for BOTH, so we track the settings value to tell them apart (used to
// hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
// rig is off so power-ON can't work).
const [catBackend, setCatBackend] = useState('');
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
const [agEnabled, setAgEnabled] = useState(false);
// Per-port optimistic selection that the status poll must not revert until the
// device confirms it (or it expires) — otherwise a stale poll right after a
// click reverts the UI and the click looks like it did nothing.
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
// 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.
// When the field is LOCKED (🔒, e.g. logging an old QSO off-frequency), we do
// NOT drive the radio — the lock means "this value is decoupled from the rig".
function onBandUserChange(v: string) {
setBand(v);
noteManualEdit();
if (catState.enabled && catState.connected && !locks.band && !locks.freq) {
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 && !locks.mode) {
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]);
// === Logbook list ===
const [qsos, setQsos] = useState<QSO[]>([]);
const [total, setTotal] = useState<number>(0);
const [error, setError] = useState('');
// Secret vault (encrypted passwords): prompt to unlock at launch when a
// passphrase is configured but not yet entered this session.
const [unlockOpen, setUnlockOpen] = useState(false);
const [unlockPass, setUnlockPass] = useState('');
const [unlockErr, setUnlockErr] = useState('');
const [unlockBusy, setUnlockBusy] = useState(false);
const doUnlock = async () => {
setUnlockBusy(true); setUnlockErr('');
try { await UnlockSecrets(unlockPass); setUnlockOpen(false); setUnlockPass(''); }
catch (e: any) { setUnlockErr(String(e?.message ?? e)); }
finally { setUnlockBusy(false); }
};
// Transient success toast (bottom-right, auto-dismiss). Used for things
// like "spot sent" where a blocking error banner would be overkill.
// Toasts are QUEUED so two firing at once don't clobber each other: each shows
// for 3s, then the next takes over (no gap). `toast` is the one on screen now.
const [toast, setToast] = useState('');
const toastQueue = useRef<string[]>([]);
const toastTimer = useRef<number | undefined>(undefined);
const advanceToast = useCallback(() => {
const next = toastQueue.current.shift();
if (next === undefined) { toastTimer.current = undefined; setToast(''); return; }
setToast(next);
toastTimer.current = window.setTimeout(advanceToast, 3000);
}, []);
const showToast = useCallback((msg: string) => {
toastQueue.current.push(msg);
if (toastTimer.current === undefined) advanceToast(); // nothing showing → start now
}, [advanceToast]);
const dismissToast = useCallback(() => {
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
advanceToast(); // skip to the next queued toast (or clear if none)
}, [advanceToast]);
// 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);
// Bumped on every (re)start so the timer resets even when `recording` was
// already true (jumping spot→spot keeps recording=true but starts a fresh take).
const [recTick, setRecTick] = 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, recTick]);
// The callsign the in-progress recording belongs to (uppercased; '' = none).
// Lets us restart from zero when the operator edits the call to a different
// station mid-recording, instead of continuing the old take.
const recordingCallRef = useRef('');
// restartRecordingForNewTarget (re)starts the take for a new target (clicked
// spot / external app / edited callsign) and resets the elapsed timer.
const restartRecordingForNewTarget = (forCall?: string) => {
if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase();
QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
};
// Reset the recording to zero (drop everything so far, pre-roll included) —
// bound to clicking the REC timer. Use when the station was already in a long
// QSO and you only want your own exchange in the file.
const resetRecordingClock = () => {
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
};
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);
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
const [eqslQsoId, setEqslQsoId] = useState<number | null>(null); // QSO being sent as eQSL
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(() => { writeUiPref('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
// Contest session: load once, then persist on every change (merge + save).
useEffect(() => {
GetUIPref('opslog.contest').then((s) => {
if (s) { try { const c = { ...CONTEST_DEFAULT, ...JSON.parse(s) }; setContest(c); if (c.active) setContestTabEnabled(true); } catch {} }
}).catch(() => {});
}, []);
const updateContest = (patch: Partial<ContestSession>) => {
setContest((c) => {
const next = { ...c, ...patch };
writeUiPref('opslog.contest', JSON.stringify(next));
return next;
});
};
// Contest dupe check: is the typed callsign already worked in this contest on
// the current band + mode? Debounced so it doesn't fire on every keystroke.
useEffect(() => {
if (!contest.active || !contest.code || !callsign.trim()) { setContestDupe(false); return; }
const call = callsign.trim().toUpperCase();
let alive = true;
const t = window.setTimeout(() => {
ContestDupe(call, contest.code, band, mode, contest.startISO).then((d) => { if (alive) setContestDupe(!!d); }).catch(() => {});
}, 250);
return () => { alive = false; window.clearTimeout(t); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [callsign, contest.active, contest.code, band, mode, contest.startISO]);
// === 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);
// Holds our station callsign for the (one-shot) cluster spot listener, so a
// self-spot can be surfaced in the shared header toast.
const myCallRef = useRef('');
// === 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
// CW keyer output engine (persisted in the WinKeyer settings, chosen in
// Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
// auto-call and <LOGQSO> are shared; only the transport differs.
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
const cwSource: 'winkeyer' | 'icom' = wkEngine === 'icom' ? 'icom' : 'winkeyer';
const cwSourceRef = useRef(cwSource);
useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
// CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
// actually transmit. Exposed in the CW keyer panel when the engine is Icom.
const [icomBreakIn, setIcomBreakIn] = useState(0);
const setBreakIn = useCallback((m: number) => { setIcomBreakIn(m); IcomSetBreakIn(m).catch(() => {}); }, []);
// Seed the current break-in from the rig when the CW panel becomes active in
// Icom mode (so the control reflects the radio's real state).
useEffect(() => {
if (cwSource !== 'icom' || !wkEnabled || !(catState.backend === 'icom' && catState.connected)) return;
GetIcomState().then((s: any) => { if (s && typeof s.break_in === 'number') setIcomBreakIn(s.break_in); }).catch(() => {});
}, [cwSource, wkEnabled, catState.backend, catState.connected]);
// Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds)
// until a reply is entered or it's stopped. Persisted as UI prefs.
const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1');
const [wkAutoCallSecs, setWkAutoCallSecs] = useState(() => Number(localStorage.getItem('opslog.wkAutoCallSecs')) || 3);
const wkAutoCallRef = useRef(wkAutoCall);
const wkAutoCallSecsRef = useRef(wkAutoCallSecs);
const autoCallGenRef = useRef(0); // bump to cancel the running loop
const autoCallMacroRef = useRef(-1); // macro index currently auto-repeating (-1 = none)
useEffect(() => { wkAutoCallRef.current = wkAutoCall; }, [wkAutoCall]);
useEffect(() => { wkAutoCallSecsRef.current = wkAutoCallSecs; }, [wkAutoCallSecs]);
// F1-F12 macro shortcuts active only when the keyer is enabled + connected.
const wkActiveRef = useRef(false);
const wkEscClearsRef = useRef(true);
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
useEffect(() => {
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) : wkStatus.connected;
wkActiveRef.current = wkEnabled && connected;
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
// === Digital Voice Keyer (DVK) ===
// CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the
// mode is CW. The decoded text appears in a strip above the tabs.
const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1');
const [cwText, setCwText] = useState('');
const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
const cwOn = cwEnabled && mode === 'CW';
// Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
const cwScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]);
// Manual pitch override ('' = Auto: follow the radio's CW pitch / search).
const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || '');
useEffect(() => {
const hz = parseInt(cwPitch, 10);
SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {});
localStorage.setItem('opslog.cwPitch', cwPitch);
}, [cwPitch, cwOn]);
useEffect(() => {
const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200)));
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
return () => { offT?.(); offS?.(); offE?.(); };
}, []);
// Start/stop the backend decoder as the (enabled, mode) combination changes.
useEffect(() => {
if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); }
else { StopCWDecoder().catch(() => {}); }
}, [cwOn]);
function toggleCwDecoder() {
setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; });
}
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
const [chatAvailable, setChatAvailable] = useState(false);
const [chatOpen, setChatOpen] = useState(false);
const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>([]);
const [chatOnline, setChatOnline] = useState<ChatPresence[]>([]);
const [chatUnread, setChatUnread] = useState(0);
const [chatEpoch, setChatEpoch] = useState(0); // bumped on profile switch to reload the chat for the new logbook
const chatOpenRef = useRef(chatOpen); chatOpenRef.current = chatOpen;
const chatSeen = useRef<Set<number>>(new Set());
// Availability (only on a shared MySQL logbook; re-checked as profiles switch).
useEffect(() => {
let alive = true;
const chk = () => ChatAvailable().then((v) => alive && setChatAvailable(!!v)).catch(() => {});
chk();
const id = window.setInterval(chk, 10000);
return () => { alive = false; window.clearInterval(id); };
}, []);
// Incoming messages — append + bump unread when the panel is closed.
useEffect(() => {
const off = EventsOn('chat:message', (m: ChatMsg) => {
if (!m || chatSeen.current.has(m.id)) return;
chatSeen.current.add(m.id);
setChatMsgs((p) => [...p, m].slice(-300));
if (!chatOpenRef.current) setChatUnread((u) => u + 1);
});
return () => { off?.(); };
}, []);
// On open: clear unread, load history + the online list (refreshed).
useEffect(() => {
if (!chatOpen) return;
setChatUnread(0);
GetChatHistory(80).then((h: any) => {
const list = (h ?? []) as ChatMsg[];
list.forEach((m) => chatSeen.current.add(m.id));
setChatMsgs((prev) => {
const byId = new Map<number, ChatMsg>();
[...list, ...prev].forEach((m) => byId.set(m.id, m));
return Array.from(byId.values()).sort((a, b) => a.id - b.id).slice(-300);
});
}).catch(() => {});
const lo = () => GetOnlineOperators().then((o: any) => setChatOnline((o ?? []) as ChatPresence[])).catch(() => {});
lo();
const id = window.setInterval(lo, 15000);
return () => window.clearInterval(id);
}, [chatOpen, chatEpoch]);
async function chatSend(t: string) {
try {
const m = (await SendChatMessage(t)) as any as ChatMsg;
if (m && m.id && !chatSeen.current.has(m.id)) {
chatSeen.current.add(m.id);
setChatMsgs((p) => [...p, m].slice(-300));
}
} catch (e: any) { setError(String(e?.message ?? e)); }
}
const chatShown = chatOpen && chatAvailable;
const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal
// Persistent spot-alert tray: keep the last few fired alerts visible (they used
// to vanish with the 3 s toast, lost if you were on another window). Each stays
// ~10 min and is clickable to tune the rig to its freq/mode.
type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown
const ALERT_TTL_MS = 10 * 60 * 1000;
useEffect(() => {
const id = window.setInterval(() => {
setRecentAlerts((prev) => { const cut = Date.now() - ALERT_TTL_MS; const n = prev.filter((a) => a.at >= cut); return n.length === prev.length ? prev : n; });
}, 20000);
return () => window.clearInterval(id);
}, []);
// Close the (now-hidden) dropdown when the last alert clears/expires.
useEffect(() => { if (recentAlerts.length === 0) setAlertsPanelOpen(false); }, [recentAlerts.length]);
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
// Contest tab is hidden until enabled from Tools → Contest mode.
const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1');
useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]);
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-mode' | '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('');
// Hide spots already worked (exact call worked, or this band+mode slot done).
const [clusterHideWorked, setClusterHideWorked] = useState(false);
// Bands shown side-by-side in the Band Map tab (portable).
const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
catch { return []; }
});
const toggleBandMapBand = useCallback((b: string) => {
setBandMapBands((cur) => {
const next = cur.includes(b) ? cur.filter((x) => x !== b) : [...cur, b];
writeUiPref('opslog.bandMapBands', JSON.stringify(next));
return next;
});
}, []);
// Global band-map-tab options (apply to every map in the tab): hide FTx/digital
// spots, and fit each map to its full band span.
const [bandMapHideFt, setBandMapHideFt] = useState(() => localStorage.getItem('opslog.bandMapHideFt') === '1');
const [bandMapFit, setBandMapFit] = useState(() => localStorage.getItem('opslog.bandMapFit') === '1');
const toggleBandMapHideFt = useCallback(() => setBandMapHideFt((v) => { const n = !v; writeUiPref('opslog.bandMapHideFt', n ? '1' : '0'); return n; }), []);
const toggleBandMapFit = useCallback(() => setBandMapFit((v) => { const n = !v; writeUiPref('opslog.bandMapFit', n ? '1' : '0'); return n; }), []);
// Single band map docked beside the table (toggled by the toolbar button,
// visible across tabs). Independent of the multi-band "Band Map" tab.
const [showBandMap, setShowBandMap] = useState(() => localStorage.getItem('bandmap.show') === '1');
// Persist the Band Map open/closed state (portable) so it survives a restart.
const setBandMapShown = useCallback((v: boolean) => {
setShowBandMap(v);
writeUiPref('bandmap.show', v ? '1' : '0');
}, []);
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
() => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
);
const toggleBandMapSide = useCallback(() => {
setBandMapSide((s) => {
const next = s === 'right' ? 'left' : 'right';
writeUiPref('bandmap.side', next);
return next;
});
}, []);
// Main tab is two configurable panes; each side shows one of the great-circle
// map ("map1"), the locator street map ("map2"), the cluster grid or the
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// so it's loaded async on mount and re-read on profile:changed below.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'netcontrol';
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
const loadMainPanes = useCallback(async () => {
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'netcontrol';
const [l, r] = await Promise.all([
GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''),
]);
setMainPaneLeft(valid(l) ? l : 'map1');
setMainPaneRight(valid(r) ? r : 'map2');
}, []);
useEffect(() => { loadMainPanes(); }, [loadMainPanes]);
// Report the current entry-strip band/mode/freq to the backend so the live
// operator status (multi-op) has band/mode/freq even when the CAT is off.
useEffect(() => {
const hz = freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0;
ReportLiveActivity(hz || 0, band || '', mode || '').catch(() => {});
}, [band, mode, freqMhz]);
// Cluster filter sidebar visibility — shared by the Cluster tab and the
// Main-view cluster pane (portable UI pref). Hiding it keeps the filters
// active, it just reclaims the width.
const [clusterShowFilters, setClusterShowFilters] = useState(() => localStorage.getItem('opslog.clusterShowFilters') !== '0');
const toggleClusterFilters = useCallback(() => {
setClusterShowFilters((v) => { const n = !v; writeUiPref('opslog.clusterShowFilters', n ? '1' : '0'); return n; });
}, []);
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);
// QSOs queued for the delete confirm (1 or many — multi-row selection).
const [deletingIds, setDeletingIds] = useState<number[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [showSettings, setShowSettings] = useState(false);
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
// 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 [showAbout, setShowAbout] = useState(false);
const [showDuplicates, setShowDuplicates] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
// Check GitHub for a newer release once at startup (unless disabled in
// General); surface a toast if one exists. Best effort — silent on failure.
useEffect(() => {
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
CheckForUpdate().then((u: any) => {
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? '') });
}).catch(() => {});
}, []);
const [deletingAll, setDeletingAll] = useState(false);
const [ctyRefreshing, setCtyRefreshing] = useState(false);
const [refsDownloading, setRefsDownloading] = 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);
const [importApplyStation, setImportApplyStation] = useState(false);
// 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);
// Per-award columns for the Recent QSOs / Worked-before grids: load the award
// list once, then compute each shown QSO's reference per award and attach it
// to the rows (the grids render one hideable column per award).
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
// Bumped whenever award definitions are saved so the grid columns AND the
// per-QSO refs re-fetch — the ref/name display choice is computed live, so
// changing it updates ALL contacts (old included) with no restart.
const [awardsVersion, setAwardsVersion] = useState(0);
useEffect(() => {
GetAwardDefs().then((defs: any[]) =>
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
).catch(() => {});
}, [awardsVersion]);
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
if (ids.length === 0 || awardCols.length === 0) { setQsoAwardRefs({}); return; }
let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; };
}, [qsos, awardCols.length, awardsVersion]);
const qsosWithAwards = useMemo(
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
[qsos, qsoAwardRefs],
);
const [wbAwardRefs, setWbAwardRefs] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const ids = ((wb?.entries ?? []) as any[]).map((e) => e.id).filter(Boolean);
if (ids.length === 0 || awardCols.length === 0) { setWbAwardRefs({}); return; }
let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; };
}, [wb, awardCols.length, awardsVersion]);
const wbWithAwards = useMemo(
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
[wb, wbAwardRefs],
);
// 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]);
// Last callsign broadcast over UDP (WSJT-X/JTDX/MSHV/DXHunter). Lets us tell
// "the field still shows the previous broadcast" (safe to update) from "the
// user has typed a different call" (must not clobber).
const lastUdpCallRef = useRef('');
// 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: '',
});
const [showFirstRun, setShowFirstRun] = useState(false);
myCallRef.current = (station.callsign || '').toUpperCase();
// Bearing/distance from operator's grid to the DX — used by the entry-strip
// azimuth, the Info tab, and the rotor compass. Grid-to-grid when both known;
// else fall back to the DX lat/lon (cty.dat-only entities carry no grid).
const dxPath = useMemo(() => {
const byGrid = pathBetween(station.my_grid, grid);
if (byGrid) return byGrid;
const myLL = gridToLatLon(station.my_grid);
if (myLL && details.lat != null && details.lon != null) {
return pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon });
}
return null;
}, [station.my_grid, grid, details.lat, details.lon]);
// Effective antenna heading(s): the rotor azimuth, transformed by the
// Ultrabeam pattern when one is active — reversed (180°) points opposite,
// bidirectional radiates both ways, normal is the heading itself.
const beamHeadings = useMemo<number[]>(() => {
if (!(rotatorHeading.enabled && rotatorHeading.ok)) return [];
const base = ((rotatorHeading.azimuth % 360) + 360) % 360;
if (ubStatus.enabled && ubStatus.connected) {
if (ubStatus.direction === 1) return [(base + 180) % 360];
if (ubStatus.direction === 2) return [base, (base + 180) % 360];
}
return [base];
}, [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
// show where the antenna physically points (boom) vs where it radiates when
// the Ultrabeam is reversed/bidirectional.
const boomHeading = useMemo<number | null>(() => (
rotatorHeading.enabled && rotatorHeading.ok ? ((rotatorHeading.azimuth % 360) + 360) % 360 : null
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
if (!(ubStatus.enabled && ubStatus.connected)) return null;
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
}, [ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// 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]);
// refresh reloads the grid. Returns false on failure. When silent, it doesn't
// surface the error — used by the startup retry, since the logbook DB (a remote
// MySQL especially) can take a few seconds to connect while the UI is already
// mounted, and we don't want to flash "db not available" during that window.
const refresh = useCallback(async (silent = false): Promise<boolean> => {
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('');
return true;
} catch (e: any) {
if (!silent) setError(String(e?.message ?? e));
return false;
}
}, [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);
const offEqsl = EventsOn('qsl:sent', ping);
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
}, [refresh]);
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
useEffect(() => {
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
return () => { off(); };
}, [showToast]);
// DX-cluster spot alerts: a matched rule fires here. Play a beep (WebAudio, no
// asset needed — CSP-safe) and/or show a toast, per the rule's chosen actions.
useEffect(() => {
const off = EventsOn('alert:fired', (p: any) => {
if (p?.sound) {
try {
const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
const ctx = new AC();
const beep = (freq: number, at: number, dur: number) => {
const o = ctx.createOscillator(); const g = ctx.createGain();
o.type = 'sine'; o.frequency.value = freq;
o.connect(g); g.connect(ctx.destination);
g.gain.setValueAtTime(0.0001, ctx.currentTime + at);
g.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + at + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + dur);
o.start(ctx.currentTime + at); o.stop(ctx.currentTime + at + dur + 0.02);
};
beep(880, 0, 0.14); beep(1320, 0.16, 0.18); // two-tone chirp
window.setTimeout(() => ctx.close().catch(() => {}), 600);
} catch { /* audio blocked — ignore */ }
}
if (p?.visual) {
const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? ` — ${p.country}` : ''}`);
// Also keep it in the persistent tray (clickable, ~10 min).
const a: RecentAlert = {
id: Date.now() + Math.random(), rule, call, band,
mode: String(p?.mode ?? ''), freq_hz: Number(p?.freq_hz ?? 0),
country: String(p?.country ?? ''), comment: String(p?.comment ?? ''), at: Date.now(),
};
setRecentAlerts((prev) => [a, ...prev.filter((x) => !(x.call === a.call && x.band === a.band))].slice(0, 3));
}
});
return () => { off(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showToast]);
// 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); };
}, []);
// Poll the Ultrabeam antenna for its connection + pattern direction.
useEffect(() => {
let alive = true;
const tick = async () => {
try { const s: any = await GetUltrabeamStatus(); if (alive) setUbStatus(s); } catch {}
};
tick();
const id = window.setInterval(tick, 3000);
return () => { alive = false; window.clearInterval(id); };
}, []);
// Poll the Antenna Genius switch for active antenna per port + the list.
// Re-read the enabled flag each tick so toggling it in Settings makes the
// top-bar icon appear/disappear without an app restart.
useEffect(() => {
let alive = true;
const tick = async () => {
try { const en: any = await GetAntGeniusSettings(); if (alive) setAgEnabled(!!en?.enabled); } catch {}
try {
const s = (await GetAntGeniusStatus()) as AGStatus;
if (!alive || !s) return;
const now = Date.now();
const pend = agPending.current;
// Keep an optimistic selection until the device confirms it or it ages out.
if (pend.a) { if (now > pend.a.t || s.port_a === pend.a.v) delete pend.a; else s.port_a = pend.a.v; }
if (pend.b) { if (now > pend.b.t || s.port_b === pend.b.v) delete pend.b; else s.port_b = pend.b.v; }
// Only update when something actually changed — avoids re-rendering the
// widget every 1.5s (which made buttons flicker on hover).
setAgStatus((prev) => (JSON.stringify(prev) === JSON.stringify(s) ? prev : s));
} catch {}
};
tick();
const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); };
}, []);
const agActivate = (port: number, antenna: number) => {
// Optimistic: reflect the change immediately and pin it for ~3s so the next
// poll (which may still carry the old cached value) can't revert it.
agPending.current[port === 1 ? 'a' : 'b'] = { v: antenna, t: Date.now() + 3000 };
setAgStatus((s) => ({ ...s, ...(port === 1 ? { port_a: antenna } : { port_b: antenna }) }));
AntGeniusActivate(port, antenna).catch((e) => setError(String(e?.message ?? e)));
};
// RX band auto-follows the TX band (only differs for cross-band work).
useEffect(() => { setBandRx(band); }, [band]);
// Logbook connection label for the status bar (MySQL host:port/db, or the
// local SQLite file path).
useEffect(() => { GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {}); }, []);
// The logbook can switch at runtime when the active profile changes (each
// profile can target its own SQLite/MySQL database). Refresh the grid and the
// status-bar label when that happens.
useEffect(() => {
const off = EventsOn('logbook:changed', () => {
GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {});
refresh();
});
return () => { off(); };
}, [refresh]);
// Live sync for a SHARED MySQL logbook: poll a cheap revision fingerprint so
// QSOs another operator's instance adds/removes show up here within a few
// seconds, without a manual refresh. Pointless on local SQLite (single writer).
useEffect(() => {
if (dbConn?.backend !== 'mysql') return;
let alive = true;
let last = '';
const tick = async () => {
try {
const rev = await GetLogbookRevision();
if (alive && last && rev !== last) await refresh();
if (alive) last = rev;
} catch { /* logbook briefly unavailable — try again next tick */ }
};
tick();
const id = window.setInterval(tick, 2000);
return () => { alive = false; window.clearInterval(id); };
}, [dbConn, refresh]);
// 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;
setCatBackend(c.backend ?? '');
} 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
}, []);
// tuneRigCAT sends freq + mode to the rig, sequenced with a short settle
// delay so an older transceiver — busy after a band/freq change — doesn't drop
// the second command (which forced a second click to apply the mode). The
// "mode before frequency" order (à la Log4OM) is an option for rigs that need
// the mode set first; default is frequency-then-mode.
async function tuneRigCAT(freqHz: number, mode: string) {
const modeFirst = localStorage.getItem('opslog.catModeBeforeFreq') === '1';
const doFreq = () => SetCATFrequency(freqHz).catch(() => {});
const doMode = () => (mode ? SetCATMode(mode).catch(() => {}) : Promise.resolve());
const settle = () => new Promise((r) => window.setTimeout(r, 150));
if (modeFirst) {
await doMode(); await settle(); await doFreq();
} else {
await doFreq();
if (mode) { await settle(); await doMode(); }
}
}
// applyModeFromSpot updates the mode AND its RST default for a fresh target
// (clicked spot / rig-driven mode change). Unlike a manual mode tweak, this
// is a new contact, so we clear the "user edited RST" flag first — otherwise
// a 599 left from a CW QSO would stick when jumping to an SSB spot.
function applyModeFromSpot(m: string) {
if (!m) return;
setMode(m);
rstUserEditedRef.current = false;
applyModePreset(m);
}
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);
}
// Clicking a spot (cluster grid or any band map): tune the rig, set the mode,
// fill the call, pre-fill POTA, (re)start the recording. Shared so every spot
// source behaves identically.
function handleSpotClick(s: any) {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
// Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic),
// then tune the rig if CAT is connected. We must not wait for the CAT status
// echo to update the display: on a remote Flex link that echo lags by seconds,
// so the frequency used to only catch up once you nudged the VFO. noteManualEdit()
// freezes the CAT-driven overwrite for 1.5s so a stale in-flight status can't
// revert the optimistic value before the new freq is reported back.
if (s.freq_hz && s.freq_hz > 0) {
const mhz = (s.freq_hz / 1_000_000).toFixed(5);
setFreqMhz(mhz);
setRxFreqMhz(mhz);
noteManualEdit();
}
if (s.band) setBand(s.band);
if (catState.connected) tuneRigCAT(s.freq_hz, m);
if (m) applyModeFromSpot(m);
onCallsignInput(s.dx_call, { force: true });
applySpotPOTA((s as any).pota_ref);
if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call);
}
// Initial grid load. The logbook (remote MySQL) may still be connecting while
// the UI is mounted, so retry quietly until the first load succeeds rather
// than leaving the grid empty until the next manual refresh.
useEffect(() => {
let alive = true;
let tries = 0;
let timer = 0;
const attempt = async () => {
if (!alive) return;
const ok = await refresh(true);
if (ok && alive) {
// The logbook is now connected — refresh the status-bar label too, in
// case its one-shot fetch ran during the startup race (before the
// backend was determined) and grabbed the wrong/stale value.
GetDBConnectionInfo().then((i) => { if (alive) setDbConn(i as any); }).catch(() => {});
} else if (!ok && alive && tries++ < 360) {
// Quick retries at first (normal startup connects in ~2 s); then keep
// trying for several minutes, because the very first migration against a
// slow remote MySQL can legitimately take that long before the logbook
// is ready. Stays silent so no "db not available" flashes meanwhile.
timer = window.setTimeout(attempt, tries < 20 ? 500 : 1000);
} else if (!ok && alive) {
refresh(); // give up quietly retrying; surface the error now
}
};
attempt();
return () => { alive = false; if (timer) window.clearTimeout(timer); };
}, [refresh]);
useEffect(() => {
(async () => {
try {
const st = await GetStartupStatus();
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
// First launch (or a never-configured profile): collect the mandatory
// station identity before anything else.
try {
const ss = await GetStationSettings();
if (!ss.callsign?.trim()) setShowFirstRun(true);
} catch {}
} catch {}
// Prompt to unlock encrypted passwords if a passphrase is configured.
try {
const ss: any = await GetSecretStatus();
if (ss?.has_passphrase && !ss?.unlocked) setUnlockOpen(true);
} catch {}
loadStation();
loadLists();
loadCATCfg();
})();
// Poll the CAT state at launch until the rig reports a frequency: the
// backend connects asynchronously and only PUSHES cat:state on change, so
// the first (freq-carrying) event can fire before this listener mounts and
// be missed — leaving the display blank until the operator next moves the
// VFO. Stops once a freq arrives, CAT is off, or after ~13s.
(async () => {
for (let i = 0; i < 16; i++) {
try {
const s = await GetCATState();
applyCatState(s);
if (!s?.enabled) break;
if (s?.connected && s.freq_hz && s.freq_hz > 0) break;
} catch { /* not ready yet */ }
await new Promise((r) => window.setTimeout(r, 800));
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user
// just typed something (freeze window) or locked a field. Shared by the live
// cat:state event and the startup poll below.
function applyCatState(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.
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.
// FlexRadio reports the exact mode for voice/CW (USB/LSB/CW…) → trust it.
// But all digital sub-modes share one radio mode (DIGU/DIGL → "DATA"), so
// when it's digital we still pick FT8/FT4/RTTY from the frequency's
// watering hole (e.g. 14.080 → FT4), else the operator's default.
// OmniRig & other rigs often can't tell digital from SSB on a digital
// freq, so for them we infer from the frequency regardless of reported mode.
if (!lk.mode) {
let nextMode = '';
if (s.backend === 'flex') {
if (s.mode === 'DATA') {
nextMode = (s.freq_hz ? inferDigitalMode(s.freq_hz) : '') || digitalDefaultRef.current || 'FT8';
} else if (s.mode) {
nextMode = s.mode;
}
} else {
const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
if (inferred) nextMode = inferred;
else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8';
else if (s.mode) nextMode = s.mode;
}
if (nextMode) {
setMode(nextMode);
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits)
}
}
}
// 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) => applyCatState(s));
return () => { unsub?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// FlexRadio: apply the per-band RX/TX antennas whenever the band changes (rig
// QSY, spot click, manual band) — so the right antennas follow the frequency.
// Skipped while the band is LOCKED (entering an old QSO off-air) and for non-Flex
// backends. The backend no-ops if the band has no configured mapping.
const lastAntBandRef = useRef('');
useEffect(() => {
if (catState.backend !== 'flex' || locks.band) return;
const b = band.trim();
if (!b || b === lastAntBandRef.current) return;
lastAntBandRef.current = b;
FlexApplyBandAntenna(b).catch(() => {});
}, [band, catState.backend, locks.band]);
// 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 — show it in the shared header
// toast (same place as the other notifications), not a separate banner.
const mine = myCallRef.current;
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
const by = cleanSpotter(sp.spotter ?? '') || '?';
const c = (sp.comment ?? '').trim();
showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`);
}
});
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(() => {
// Apply a UDP-broadcast callsign, but never clobber what the operator is
// typing: only update when the field is empty, already shows this call, or
// still shows the previous broadcast (i.e. the field content is ours, not
// a different call the user typed). Returns true if it actually changed.
// force = true means an EXPLICIT action (a panadapter/spot click, a remote
// "set call" command) that should always win, even over a call the operator
// already typed. Only WSJT-X's CONTINUOUS DX-call stream keeps the no-clobber
// guard (it re-broadcasts constantly and must not fight what you typed).
const applyUdpCall = (raw: string, force = false): boolean => {
const call = String(raw ?? '').trim();
if (!call) return false;
const upper = call.toUpperCase();
const current = callsignValRef.current.trim().toUpperCase();
const prev = lastUdpCallRef.current;
lastUdpCallRef.current = upper; // remember this broadcast either way
if (current === upper) return false; // already shown → no-op
if (!force && current !== '' && current !== prev) return false; // user typed a different call → leave it
onCallsignInput(call, { force: true }); // programmatic → always look up
return true;
};
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
// Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
// over UDP…) is an explicit pick → force it over an existing call.
const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
// External app moved to a new station → fresh recording for the new target.
if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
});
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
});
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
// An explicit click always wins over whatever call is currently in the field.
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
const call = String(p?.call ?? '');
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
});
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?.(); unsubFlexSpot?.(); 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);
setWkEngine(s.engine === 'icom' ? 'icom' : 'winkeyer');
} catch { /* keyer not configured */ }
}, []);
// Every setting is per-profile, so when the active profile changes the whole
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
// side reloads its managers; this keeps the React state in sync.
useEffect(() => {
const off = EventsOn('profile:changed', () => {
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
// The chat is per shared logbook — clear the previous profile's messages
// and reload for the new logbook (or hide if it isn't a MySQL log).
setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0);
ChatAvailable().then((v: any) => setChatAvailable(!!v)).catch(() => setChatAvailable(false));
setChatEpoch((e) => e + 1);
});
return () => { off(); };
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]);
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_string || '', CONT_TX: details.stx_string || '',
};
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();
}
async function wkSend(rawText: string) {
setWkSent('');
const resolved = resolveCW(rawText);
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
if (cwSourceRef.current === 'icom') {
// The rig's keyer gives no busy echo back, so show the text we sent and,
// for <LOGQSO>, wait the estimated send duration before logging.
setWkSent(resolved);
await IcomSendCW(resolved).catch((e) => setError(String(e?.message ?? e)));
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
return;
}
await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
// finished sending — so the QSO isn't logged (and the form cleared) while CW
// is still going out. We'd like to wait for the busy flag to rise then fall,
// but over a remote/serial-over-IP link that status echo can lag by tens of
// seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED
// send duration (text length × WPM) plus slack: log when busy clears OR the
// estimate elapses, whichever comes first.
if (!doLog) return;
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
const deadline = Date.now() + capMs;
while (wkBusyRef.current && Date.now() < deadline) await sleep(50);
void save();
}
// stopAutoCall cancels any running auto-call loop.
function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; }
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
// gap, then resends — looping until cancelled (reply entered, Stop, unchecked).
async function runAutoCall(i: number) {
const gen = ++autoCallGenRef.current;
autoCallMacroRef.current = i;
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
while (autoCallMacroRef.current === i && gen === autoCallGenRef.current && wkActiveRef.current) {
const m = wkMacros[i];
if (!m) break;
await wkSend(m.text);
// Wait for the message to finish before the gap+resend. The Icom keyer has
// no busy echo, so just wait the estimated send time. For the WinKeyer, cap
// the wait at the ESTIMATED send time (not the busy flag alone): over a
// remote/serial-over-IP link the "busy" status lags badly and stays stuck
// true for tens of seconds, which made the next CQ fire ~120s late.
if (cwSourceRef.current === 'icom') {
await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300);
} else {
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
const deadline = Date.now() + capMs;
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50);
}
if (gen !== autoCallGenRef.current) break;
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
}
}
function wkSendMacro(i: number) {
const m = wkMacros[i];
if (!m) return;
// Auto-call only loops CQ-type macros. Sending any other macro (e.g. a
// report once someone answers) sends ONCE and cancels a running loop —
// otherwise a report would keep repeating.
const isCQ = (m.text || '').toUpperCase().includes('CQ');
if (wkAutoCallRef.current && isCQ) {
runAutoCall(i); // loop this CQ until a reply is sent / Stop / ESC
} else {
stopAutoCall();
wkSend(m.text);
}
}
wkSendMacroRef.current = wkSendMacro;
function wkToggleAutoCall(on: boolean) {
setWkAutoCall(on);
writeUiPref('opslog.wkAutoCall', on ? '1' : '0');
if (!on) stopAutoCall();
}
function wkSetAutoCallSecs(n: number) {
const v = Math.max(0, Math.min(120, n || 0));
setWkAutoCallSecs(v);
writeUiPref('opslog.wkAutoCallSecs', String(v));
}
// 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 baseStart = qsoStartedAt ?? now;
// End: explicit when locked; for a deferred back-entry (start locked) it
// defaults to the back-dated start so the QSO doesn't span to today;
// otherwise it's now.
const end = (locks.end && qsoEndedAt) ? qsoEndedAt : (locks.start ? baseStart : now);
// Option: log TIME_ON = TIME_OFF (the moment the QSO completes). Useful
// when you call a station for a long time — otherwise TIME_ON is frozen at
// when you first entered the call (minutes early) and won't match LoTW.
const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
const start = (startEqualsEnd && !locks.start) ? end : baseStart;
// Contest exchanges: a purely-numeric exchange → ADIF SRX/STX (int); an
// alphanumeric one (e.g. a section/zone) → SRX_STRING/STX_STRING.
const srxE = exchangeFields(details.srx_string);
const stxE = exchangeFields(details.stx_string);
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: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
email: details.email,
};
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
// Contest mode overrides the exchange fields: CONTEST_ID, the sent
// exchange (running serial or fixed value) and the received exchange.
if (contest.active && contest.code) {
const sent = contest.exchange === 'serial' ? String(contest.nextSerial) : contest.sentFixed;
const sE = exchangeFields(sent);
const rE = exchangeFields(rcv.trim());
Object.assign(payload, {
contest_id: contest.code,
stx: sE.num, stx_string: sE.str,
srx: rE.num, srx_string: rE.str,
});
}
await AddQSO(payload);
// Advance the sent serial only after a successful log.
if (contest.active && contest.code && contest.exchange === 'serial') {
updateContest({ nextSerial: contest.nextSerial + 1 });
}
resetEntry(); // clears the call AND the Worked-before matrix
callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name)
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); recordingCallRef.current = "";
setCallsign(''); setComment(''); setNote(''); setRcv('');
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) {
setEditingQSO(null);
setDeletingIds([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}`);
}
function openBulkEdit(ids: number[]) {
if (ids.length === 0) return;
setBulkEditIds(ids);
setBulkEditOpen(true);
}
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)); }
}
// Cabrillo (contest log) export → a .log file. The contest name comes from the
// QSOs' ADIF CONTEST_ID (no prompt). All / filtered / selected mirror the ADIF ones.
async function exportCabrillo() {
try {
const path = await SaveCabrilloFile();
if (!path) return;
const r = await ExportCabrillo(path);
showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
async function exportFilteredCabrillo() {
try {
const path = await SaveCabrilloFile();
if (!path) return;
const f = buildActiveFilter();
const r = await ExportCabrilloFiltered(path, { ...f, limit: 0, offset: 0 } as any);
showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
async function exportSelectedCabrillo(ids: number[]) {
if (ids.length === 0) return;
try {
const path = await SaveCabrilloFile();
if (!path) return;
const r = await ExportCabrilloSelected(path, ids as any);
showToast(`Cabrillo: ${r.count} selected QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
function askDelete(id: number) { setDeletingIds([id]); }
// Delete the whole multi-row selection (Edit menu / Delete key).
function askDeleteSelected() {
if (selectedIds.length > 0) setDeletingIds(selectedIds);
else if (selectedId != null) setDeletingIds([selectedId]);
}
async function confirmDelete() {
if (deletingIds.length === 0) return;
const ids = deletingIds;
try {
if (ids.length === 1) await DeleteQSO(ids[0]);
else await DeleteQSOs(ids as any);
setDeletingIds([]);
setSelectedId(null);
setSelectedIds([]);
await refresh();
} catch (err: any) {
setError(String(err?.message ?? err));
setDeletingIds([]);
}
}
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);
// Discard a STALE result: the operator already moved to another call
// (clicked a new spot / typed) while this lookup was in flight. Applying it
// would clobber the current call's fields and zoom the map to the wrong
// station — the bug where replacing a call didn't re-zoom the map.
if (call !== callsignValRef.current.trim().toUpperCase()) return;
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')) {
if ((r.grid ?? '') !== '') setGrid(r.grid ?? '');
// No provider grid (cty.dat-only / portable): derive a 4-char grid from
// the entity centroid (e.g. Svalbard → JQ88) so the field — and the
// bearing/map — aren't empty. 4 chars signals it's entity-level, not a
// precise QTH (matches how Log4OM shows it).
else if (r.lat || r.lon) setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4));
}
// 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);
// The DX location is now known (grid set above) — force the world map to
// auto-zoom right away, so it doesn't lag behind the resolved QSO.
setMapZoomSignal((n) => n + 1);
// Recording: tie it to the resolved callsign. Start once a real (≥3-char)
// call resolves — covers the fast CW workflow (type → Enter, no blur). If
// we're already recording a DIFFERENT call (the operator edited the
// callsign), restart from zero instead of continuing the old take.
const recCall = call.toUpperCase();
if (recordingCallRef.current && recordingCallRef.current !== recCall) {
restartRecordingForNewTarget(recCall);
} else {
recordingCallRef.current = recCall;
QSOAudioBegin().then(setRecording).catch(() => {});
}
} catch (e: any) {
setLookupResult(null);
setLookupError(String(e?.message ?? e));
} finally { setLookupBusy(false); }
}
function scheduleLookup(value: string, force?: boolean) {
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;
}
// Option: defer the (network) callsign lookup until the operator leaves the
// Call field instead of firing it as they type. `force` (programmatic set —
// clicked spot / external app) always looks up, since there's no blur to
// wait for. Worked-before stays live (local, feeds the band matrix).
if (force || localStorage.getItem('opslog.lookupOnBlur') !== '1') {
lookupTimerRef.current = window.setTimeout(() => runLookup(call), force ? 0 : 400);
}
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
}
// applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's
// park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh
// target). Routed to the pota_ref column at save via applyAwardRefs.
function applySpotPOTA(potaRef?: string) {
const refs = String(potaRef || '')
.split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') }));
}
function onCallsignInput(v: string, opts?: { force?: boolean }) {
// Programmatic call-sets (force: spot click, UDP, external app) count as
// "not manually typed", so a later UDP DX call (DXHunter remote control) can
// still replace it. Without this, clicking a cluster spot froze the call:
// applyUdpCall saw current != lastUdpCall and refused every later UDP call.
if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
// A callsign appeared (someone answered the CQ, or a spot was clicked) →
// stop auto-calling so we don't key over the contact. If a CQ was actually
// in flight, abort the current transmission too so it stops IMMEDIATELY
// rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
// the first keystroke, so we only abort once.)
if (v.trim() !== '') {
if (autoCallMacroRef.current !== -1) WinkeyerStop().catch(() => {});
stopAutoCall();
}
// 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); recordingCallRef.current = ""; }
const isEmpty = v.trim() === '';
if (!isEmpty && !locks.start) {
// Restart the start time on every callsign change (each keystroke, a
// clicked spot, a new call): "Start UTC" should mark when you actually
// began this contact, not a stale time frozen at the first keystroke.
// Skip when start is locked (back-entering a past QSO at a chosen time).
setQsoStartedAt(new Date());
} else if (isEmpty && !locks.start) {
// Callsign wiped → user abandoned this QSO; reset the timer.
setQsoStartedAt(null);
}
setCallsign(v);
// opts.force = the call was set programmatically (clicked spot / external
// app): there's no "leaving the field", so look it up now regardless of the
// lookup-on-blur option.
scheduleLookup(v, opts?.force);
}
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);
// Green success toast (auto-dismiss) — not the red error banner.
showToast(`ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`);
} 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, importApplyStation);
setImportResult(res);
await refresh();
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setImporting(false);
setImportProgress(null);
}
}
const menus: Menu[] = useMemo(() => [
{ name: 'file', label: t('menu.file'), items: [
{ type: 'item', label: t('file.import'), action: 'file.import', shortcut: 'Ctrl+O' },
{ type: 'item', label: exporting ? t('file.exporting') : t('file.export'), action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
{ type: 'item', label: t('file.exportCabrillo'), action: 'file.exportCabrillo', disabled: total === 0 },
{ type: 'separator' },
{ type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 },
{ type: 'separator' },
{ type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true },
]},
{ name: 'edit', label: t('menu.edit'), items: [
{ type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
{ type: 'item', label: selectedIds.length > 1 ? `Delete ${selectedIds.length} selected QSOs` : 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
{ type: 'item', label: selectedIds.length > 1 ? `Bulk edit field (${selectedIds.length})…` : 'Bulk edit field…', action: 'edit.bulkedit', disabled: selectedIds.length === 0 },
{ type: 'separator' },
{ type: 'item', label: t('edit.prefs'), action: 'edit.prefs' },
]},
{ name: 'view', label: t('menu.view'), items: [
{ type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
{ type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' },
]},
{ name: 'tools', label: t('menu.tools'), items: [
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
{ type: 'separator' },
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
{ type: 'separator' },
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
{ type: 'item', label: t('tools.alerts'), action: 'tools.alerts' },
{ type: 'separator' },
{ type: 'item', label: t('tools.duplicates'), action: 'tools.duplicates', disabled: total === 0 },
{ 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 },
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
]},
{ name: 'help', label: t('menu.help'), items: [
{ type: 'item', label: t('help.about'), action: 'help.about' },
]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, t]);
function handleMenu(action: string) {
switch (action) {
case 'file.import': importAdif(); break;
case 'file.export': exportAdif(); break;
case 'file.exportCabrillo': exportCabrillo(); 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': askDeleteSelected(); break;
case 'edit.bulkedit': openBulkEdit(selectedIds); break;
case 'edit.prefs': setShowSettings(true); break;
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
case 'tools.dvk': setDvkEnabled((v) => !v); break;
case 'tools.cwdecoder': toggleCwDecoder(); break;
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
case 'tools.alerts': setAlertsOpen(true); break;
case 'tools.duplicates': setShowDuplicates(true); break;
case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); break;
}
}
async function downloadRefs() {
if (refsDownloading) return;
setRefsDownloading(true);
setError('');
try {
const summary = await DownloadAllReferenceLists();
showToast(`Reference lists updated — ${summary}`);
} catch (e: any) {
setError(`Reference download failed: ${String(e?.message ?? e)}`);
} finally {
setRefsDownloading(false);
}
}
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;
// ESC aborts the current CW transmission AND the auto-call loop, so it
// won't resend after the gap — you must click a CQ macro to restart it.
if (keyerLive) { stopAutoCall(); 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(); askDeleteSelected(); 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, selectedIds, 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-44">
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
{t('field.callsign')}
{lookupBusy && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted/70 px-1.5 py-[1px] text-[9px] font-medium text-muted-foreground ring-1 ring-inset ring-border/60">
<Loader2 className="size-2.5 animate-spin" />
</span>
)}
{!lookupBusy && lookupResult && (() => {
const src = (lookupResult.source || '').toLowerCase();
const tone = src.includes('cache') ? 'amber' : src.includes('qrz') ? 'emerald' : src.includes('ham') ? 'sky' : 'slate';
const ring = { amber: 'bg-warning/12 text-warning ring-warning/30', emerald: 'bg-success/12 text-success ring-success/30', sky: 'bg-info/12 text-info ring-info/30', slate: 'bg-muted-foreground/12 text-muted-foreground ring-muted-foreground/30' }[tone];
const dot = { amber: 'bg-warning', emerald: 'bg-success', sky: 'bg-info', slate: 'bg-muted-foreground' }[tone];
return (
<span className={cn('inline-flex items-center gap-1 rounded-full px-1.5 py-[1px] text-[9px] font-semibold uppercase tracking-wide ring-1 ring-inset', ring)}>
<span className={cn('size-1.5 rounded-full', dot)} />{lookupResult.source}
</span>
);
})()}
{!lookupBusy && !lookupResult && lookupError && (
<span className="inline-flex items-center rounded-full bg-danger/12 px-1.5 py-[1px] text-[9px] font-semibold text-danger ring-1 ring-inset ring-danger/30">{lookupError}</span>
)}
{callsign.trim() && (
<button
type="button"
title={t('qrz.openTitle', { call: callsign.trim().toUpperCase() })}
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="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
>
QRZ
</button>
)}
</Label>
<div className="relative">
{contest.active && contestDupe && (
<span className="absolute -top-2 right-1 z-20 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-black tracking-widest text-destructive-foreground shadow animate-pulse pointer-events-none">
DUPE
</span>
)}
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={resetRecordingClock}
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
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-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted"
>
<span className="size-2 rounded-full bg-destructive animate-pulse" />
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
</button>
)}
<Input
ref={callsignRef}
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground')}
value={callsign}
onChange={(e) => onCallsignInput(e.target.value)}
onBlur={() => {
const c = callsign.trim();
if (!c) return;
// Lookup-on-blur mode: run the deferred lookup now (it also starts the
// recording). Otherwise just start the recording (lookup already ran).
if (localStorage.getItem('opslog.lookupOnBlur') === '1') runLookup(c.toUpperCase());
else QSOAudioBegin().then(setRecording).catch(() => {});
}}
/>
</div>
</div>
);
const rstTxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
</div>
);
const rstRxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
</div>
);
// DX country flag, shown large next to RST (moved here from the Country field).
const flagBlock = flagURL(details.dxcc) ? (
<div className="flex flex-col justify-end shrink-0">
<img src={flagURL(details.dxcc)} alt={country} title={country}
className="h-9 rounded-[3px] border border-border/60 shadow-sm"
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
</div>
) : null;
// Deferred-entry date: only shown when the start time is locked (back-entering
// a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time.
const dateBlock = locks.start ? (
<div className="flex flex-col w-40">
<Label className="mb-1 h-3.5 text-success">Date</Label>
<Input
type="date"
value={qsoStartedAt ? qsoStartedAt.toISOString().slice(0, 10) : ''}
onChange={(e) => {
const d = e.target.value; // YYYY-MM-DD
if (!d) return;
const [y, mo, da] = d.split('-').map(Number);
const next = new Date(qsoStartedAt ?? new Date());
next.setUTCFullYear(y, mo - 1, da);
setQsoStartedAt(next);
}}
className="font-mono"
/>
</div>
) : null;
const startBlock = (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <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-danger">{t('field.endUtc')} <LockBtn k="end" title="end time" /></Label>
<Input
readOnly={!locks.end}
tabIndex={locks.end ? 0 : -1}
value={endFocused ? endInputStr : (
locks.end ? (qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : '')
// Deferred entry (start locked): the QSO is back-dated, so the end
// follows the start instead of the live clock.
: locks.start ? (qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : '')
: (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">{t('field.name')}</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</div>
);
// In contest mode QTH is replaced by the sent (read-only, auto) and received
// exchange fields — the exchange is what matters, not the town.
const contestSnt = contest.exchange === 'serial'
? String(contest.nextSerial).padStart(3, '0')
: (contest.sentFixed || '—');
const qthBlock = contest.active ? (
<>
<div className="flex flex-col w-16 shrink-0"><Label className="mb-1 h-3.5">{t('field.snt')}</Label>
<Input readOnly tabIndex={-1} value={contestSnt} className="font-mono bg-muted/40" />
</div>
<div className="flex flex-col w-24 shrink-0"><Label className="mb-1 h-3.5 text-warning font-bold">{t('field.rcv')}</Label>
<Input value={rcv} placeholder="exch" className="font-mono"
onChange={(e) => setRcv(e.target.value.toUpperCase())} />
</div>
</>
) : (
<div className="flex flex-col flex-[0.55] min-w-[70px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
</div>
);
const gridBlock = (
<div className="flex flex-col w-28 shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</div>
);
// A discreet spot-alert LED (bell) that fills the gap at the right of the Grid
// row instead of the intrusive floating cards. It lights + shows a count when
// alerts are pending; click it to see the last 3 (each clickable to tune).
const hasAlerts = recentAlerts.length > 0;
// Only show the bell when there are pending alerts — hidden otherwise.
const alertLedBlock = !hasAlerts ? null : (
<div className="relative self-end mb-0.5 shrink-0">
<button type="button" onClick={() => setAlertsPanelOpen((o) => !o)}
title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')}
className={cn('relative inline-flex size-8 items-center justify-center rounded-full border transition-colors',
hasAlerts ? 'border-warning bg-warning/15 text-warning' : 'border-border bg-card text-muted-foreground hover:bg-muted')}>
<Bell className="size-4" />
{hasAlerts && <span className="absolute -right-1 -top-1 inline-flex size-2.5 rounded-full bg-warning animate-ping" />}
{hasAlerts && <span className="absolute -right-1 -top-1 inline-flex size-2.5 rounded-full bg-warning" />}
</button>
{alertsPanelOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setAlertsPanelOpen(false)} />
<div className="absolute right-0 top-9 z-50 w-72 rounded-lg border border-border bg-card shadow-xl overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/60 bg-muted/30">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{t('alert.recent')}</span>
{hasAlerts && <button type="button" className="text-[11px] text-muted-foreground hover:text-foreground" onClick={() => setRecentAlerts([])}>{t('alert.clear')}</button>}
</div>
{!hasAlerts ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">{t('alert.noneShort')}</div>
) : recentAlerts.map((a) => (
<button key={a.id} type="button" title={t('alert.tuneHint')}
onClick={() => { handleSpotClick({ comment: a.comment, freq_hz: a.freq_hz, band: a.band, dx_call: a.call }); setAlertsPanelOpen(false); }}
className="block w-full text-left px-3 py-2 border-b border-border/30 last:border-0 hover:bg-warning/10 transition-colors">
<div className="flex items-center gap-1.5">
<span className="font-bold text-sm truncate">{a.call}</span>
{a.freq_hz > 0 && <span className="ml-auto shrink-0 text-[11px] font-mono tabular-nums text-warning">{(a.freq_hz / 1e6).toFixed(3)}</span>}
</div>
<div className="text-[11px] text-muted-foreground truncate">
{a.rule}{a.band ? ` · ${a.band}` : ''}{a.mode ? ` · ${a.mode}` : ''}{a.country ? ` · ${a.country}` : ''}
</div>
</button>
))}
</div>
</>
)}
</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">{t('field.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">{t('field.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">{t('field.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">{t('field.country')}</Label>
<div className="flex-1 min-w-0">
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
</div>
</div>
);
// CQ/ITU zones moved to the Info (F2) tab (DetailsPanel).
const freqBlock = (
<div className="flex flex-col w-32">
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <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-danger')}>{t('field.rxFreq')}</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-danger-muted/40 border-danger-border focus:bg-card')}
/>
</div>
);
const bandRxBlock = (
<div className="flex flex-col w-28"><Label className="mb-1 h-3.5">{t('field.rxBand')}</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">{t('field.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">{t('field.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" />
{t('btn.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" />
{t('btn.clear')}
</Button>
<Button onClick={save} disabled={saving} className="h-8">
<Send className="size-3.5" />
{saving ? '…' : t('btn.logQso')}
</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 ? '…' : t('btn.logQso')}
</Button>
</div>
);
// Cluster spots after every active filter (band / mode / status / search /
// hide-worked / group). Shared by the Cluster tab and the Main-view cluster
// pane so both show exactly the same list.
const clusterRenderedRows = useMemo(() => {
const bandsActive = clusterLockBand ? new Set([band]) : clusterBands;
const search = clusterSearch.trim().toUpperCase();
const 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 e = spotStatus[k];
const st = (e?.status || '') as SpotStatusKey;
// A previously-worked call counts as WORKED for filtering even when its
// entity status is still new-band/new-slot (the grid flags it WKD CALL),
// matching the "Hide worked" toggle. Additive: it still matches its own
// entity status too, so it stays visible under NEW BAND / NEW SLOT.
const matches = clusterStatusFilter.has(st) || (!!e?.worked_call && clusterStatusFilter.has('worked'));
if (!matches) return false;
}
if (clusterHideWorked) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const e = spotStatus[k];
if (!e) return false;
if (e.worked_call || e.status === 'worked') 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());
}
return rendered;
}, [spots, clusterLockBand, band, clusterBands, clusterSearch, clusterFilterSource,
clusterLockMode, mode, clusterModeFilter, clusterStatusFilter, spotStatus,
clusterHideWorked, clusterGroup]);
// The Log4OM-style cluster filter sidebar (callsign search, hide-worked,
// group, band/mode/status/source). Rendered both in the Cluster tab and the
// Main-view cluster pane; toggled by clusterShowFilters.
const renderClusterFilters = () => (
<div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
<div className="px-2.5 py-2 border-b border-border/60 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filters</span>
<button type="button" onClick={toggleClusterFilters} title="Hide filters"
className="text-muted-foreground hover:text-foreground">
<X className="size-3.5" />
</button>
</div>
<div className="flex-1 overflow-auto p-2.5 space-y-3 text-xs">
{/* Callsign search */}
<Input
className="h-7 text-xs font-mono uppercase"
placeholder="Search call…"
value={clusterSearch}
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
/>
{/* Toggles */}
<div className="space-y-1.5">
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterHideWorked} onCheckedChange={(c) => setClusterHideWorked(!!c)} />
Hide worked
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
Group duplicates
</label>
</div>
{/* Band filter — multi-select listbox */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">Bands</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setClusterLockBand((v) => !v)}
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
clusterLockBand ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
title="Lock to the entry strip's current band"
>
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
</button>
{clusterBands.size > 0 && (
<button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">clear</button>
)}
</div>
</div>
<div className={cn('rounded border border-border max-h-36 overflow-auto bg-background', clusterLockBand && 'opacity-40 pointer-events-none')}>
{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('block w-full text-left px-2 py-0.5 font-mono text-[11px] border-b border-border/30 last:border-0',
on ? 'bg-primary text-primary-foreground' : 'hover:bg-accent/50')}
>
{b}
</button>
);
})}
</div>
</div>
{/* Mode lock */}
<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]',
clusterLockMode ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : '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>
{/* Status filter */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
<div className="flex flex-wrap gap-1">
{([
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
{ k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
]).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>
</div>
{/* Mode filter */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
<div className="flex flex-wrap gap-1">
{([
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
]).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>
</div>
{/* Source */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Source</div>
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
<SelectTrigger className="w-full 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>
</div>
</div>
);
// A small "show filters" button shown when the sidebar is collapsed.
const clusterFiltersToggleBtn = (
<button type="button" onClick={toggleClusterFilters}
title={clusterShowFilters ? 'Hide filters' : 'Show filters'}
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium',
clusterShowFilters ? 'bg-primary text-primary-foreground border-primary' : 'text-muted-foreground border-border hover:bg-muted')}>
<SlidersHorizontal className="size-3" /> Filters
</button>
);
// Render one Main-view pane. The two sides (mainPaneLeft/Right) each pick from
// the same four choices, configured per-profile in Settings → Main view.
const renderMainPane = (kind: MainPaneKind) => {
switch (kind) {
case 'map1':
return (
<WorldMap
fromGrid={station.my_grid}
toGrid={grid}
fromLabel={station.callsign}
toLabel={callsign}
beamAzimuths={showBeamOnMap ? beamHeadings : []}
boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null}
zoomSignal={mapZoomSignal}
/>
);
case 'map2':
return <LocatorMap toGrid={grid} toLabel={callsign} />;
case 'cluster':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-2 py-1 border-b border-border/60 shrink-0">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Cluster</span>
{clusterFiltersToggleBtn}
</div>
<div className="flex-1 min-h-0 flex">
<div className="flex-1 min-w-0 flex flex-col min-h-0">
<ClusterGrid rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
</div>
{clusterShowFilters && renderClusterFilters()}
</div>
</div>
);
case 'worked':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
</div>
);
case 'flex':
return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</div>
);
case 'icom':
return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</div>
);
case 'netcontrol':
return (
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
</div>
);
case 'recent':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<RecentQSOsGrid
rows={qsosWithAwards as any}
total={total}
awardCols={awardCols}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ}
onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo}
onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit}
onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
/>
</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-success-muted text-success-muted-foreground hover:bg-success-muted 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)]" />
<div className="flex items-baseline gap-1.5">
<span className="font-bold text-[15px] tracking-tight leading-none">OpsLog</span>
<span className="text-[11px] text-muted-foreground leading-none cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
</div>
</div>
<Menubar menus={menus} onAction={handleMenu} />
<div className="relative flex items-center justify-center gap-2 font-mono">
{/* Transient toast / error, in the empty band between the menu and
the frequency (left of centre). Single-line + truncated. */}
{(error || toast) && (
<div className="absolute left-1 top-1/2 -translate-y-1/2 z-20 flex items-center max-w-[min(42vw,560px)] font-sans">
{error ? (
<div className="flex items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/10 text-destructive px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<AlertCircle className="size-3.5 shrink-0" />
<span className="truncate" title={error}>{error}</span>
<button className="shrink-0 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3" /></button>
</div>
) : (
<div className="flex items-center gap-1.5 rounded-md border border-success-border bg-success-muted text-success-muted-foreground px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<Satellite className="size-3.5 shrink-0" />
<span className="truncate" title={toast}>{toast}</span>
<button className="shrink-0 text-success hover:text-success" onClick={dismissToast}><X className="size-3" /></button>
</div>
)}
</div>
)}
<div className="flex flex-col items-end leading-none">
<span className="text-2xl 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-danger 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-warning-muted text-warning-muted-foreground border-warning-border 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 = dxPath;
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-info-border bg-info-muted 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-info-muted-foreground hover:bg-info-muted 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-info-border text-[10px] transition-colors',
disabled
? 'text-muted-foreground/60 cursor-not-allowed'
: 'text-info-muted-foreground hover:bg-info-muted 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-info-border text-danger hover:bg-danger-muted hover:text-danger-muted-foreground cursor-pointer transition-colors"
>
<Square className="size-2.5 fill-current" />
</button>
</div>
);
})()}
{/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
{ubStatus.enabled && (
<div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam pattern') : 'Ultrabeam: connecting…'}>
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
</button>
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
<button key={o.d} type="button" disabled={!ubStatus.connected} title={o.t}
onClick={() => { SetUltrabeamDirection(o.d).then(() => setUbStatus((s) => ({ ...s, direction: o.d }))).catch((e: any) => setError(String(e?.message ?? e))); }}
className={cn('px-1.5 py-0.5 transition-colors',
ubStatus.direction === o.d ? 'bg-success text-success-foreground' : 'text-success-muted-foreground hover:bg-success-muted',
!ubStatus.connected && 'opacity-40 cursor-default')}>
{o.l}
</button>
))}
</div>
)}
{/* Voice keyer (DVK) + CW keyer (WinKeyer) quick status/access. */}
<div className="w-px h-4 bg-border mx-1" />
<button
type="button"
onClick={() => setDvkEnabled((v) => !v)}
title={dvkStat.playing ? 'Voice keyer — playing' : dvkEnabled ? 'Voice keyer (DVK) — open · click to close' : 'Voice keyer (DVK) · click to open'}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
dvkStat.playing ? 'border-danger-border bg-danger-muted text-danger-muted-foreground'
: dvkEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Mic className="size-4" />
{dvkStat.playing && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-danger animate-pulse" />}
</button>
<button
type="button"
onClick={() => wkSetEnabled(!wkEnabled)}
title={wkEnabled && wkStatus.connected ? `CW keyer (WinKeyer) — connected${wkStatus.busy ? ', sending' : ''} · click to close` : wkEnabled ? 'CW keyer (WinKeyer) — enabled · click to close' : 'CW keyer (WinKeyer) · click to open'}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
wkStatus.busy ? 'border-warning-border bg-warning-muted text-warning-muted-foreground'
: wkEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Zap className="size-4" />
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
</button>
<button
type="button"
onClick={toggleCwDecoder}
title={
cwEnabled
? (mode === 'CW' ? 'CW decoder — on (decoding) · click to disable' : 'CW decoder — on, idle until CW mode · click to disable')
: 'CW decoder · click to enable (decodes RX audio in CW mode)'
}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
cwOn && cwStatus.active ? 'border-success bg-success-muted text-success-muted-foreground'
: cwEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Ear className="size-4" />
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
</button>
<button
type="button"
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
title={showRotor ? 'Rotor compass — shown · click to hide' : 'Rotor compass · click to show'}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showRotor ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Compass className="size-4" />
</button>
{agEnabled && (
<button
type="button"
onClick={() => { const v = !showAntGenius; setShowAntGenius(v); writeUiPref('opslog.showAntGenius', v ? '1' : '0'); }}
title={showAntGenius ? 'Antenna Genius — shown · click to hide' : 'Antenna Genius · click to show'}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showAntGenius ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Antenna className="size-4" />
{showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
</button>
)}
{chatAvailable && (
<button
type="button"
onClick={() => setChatOpen((o) => !o)}
title={`Multi-op chat${chatUnread > 0 ? ` — ${chatUnread} new` : ''}`}
className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
chatShown ? 'border-info-border bg-info-muted text-info-muted-foreground hover:bg-info-muted'
: 'border-border text-muted-foreground hover:bg-muted')}
>
<MessageSquare className="size-4" />
{chatUnread > 0 && (
<span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-danger text-danger-foreground text-[9px] font-bold leading-[14px] text-center">
{chatUnread > 9 ? '9+' : chatUnread}
</span>
)}
</button>
)}
</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-card/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={() => setBandMapShown(!showBandMap)}
title="Toggle a single band map docked on the side (use the Band Map tab for several at once)"
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>
)}
{/* First launch: mandatory station identity. Blocks until filled. */}
{showFirstRun && (
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
)}
{updateInfo && (
<div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
<div className="flex items-start gap-2">
<div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">OpsLog v{updateInfo.latest} available</p>
<p className="text-xs text-muted-foreground">You're on v{APP_VERSION}.</p>
<div className="mt-2 flex items-center gap-2">
<button
onClick={() => { if (updateInfo.url) BrowserOpenURL(updateInfo.url); setUpdateInfo(null); }}
className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
Download
</button>
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">
Later
</button>
</div>
</div>
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
<X className="size-4" />
</button>
</div>
</div>
)}
{showAbout && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowAbout(false)}>
<div className="w-full max-w-sm rounded-xl border border-border bg-card shadow-2xl p-6 text-center animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-2 mb-1">
<div className="size-3 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
<h2 className="text-xl font-bold tracking-tight">OpsLog</h2>
</div>
<p className="text-sm text-muted-foreground">Ham-radio logbook</p>
<p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p>
{updateInfo ? (
<button onClick={() => updateInfo.url && BrowserOpenURL(updateInfo.url)} className="mt-1 text-xs text-primary underline hover:opacity-80">
Update available: v{updateInfo.latest} — download
</button>
) : (
<p className="mt-1 text-[11px] text-success">You're up to date</p>
)}
<p className="mt-3 text-sm">
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
</p>
<p className="mt-1 text-[11px] text-muted-foreground">73 & good DX</p>
<button onClick={() => setShowAbout(false)} className="mt-5 h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">
Close
</button>
</div>
</div>
)}
{/* Transient toasts (bottom-right). Errors stack on top of the green
success toast; both auto-dismiss. */}
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
skipping leaves lookups/uploads without their passwords until unlocked. */}
{unlockOpen && (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-[360px] rounded-lg border border-border bg-card shadow-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Lock className="size-4 text-primary" />
<h2 className="text-sm font-semibold">Unlock saved passwords</h2>
</div>
<p className="text-xs text-muted-foreground mb-3">
Enter your passphrase to decrypt your QRZ / HamQTH / LoTW / SMTP passwords for this session.
</p>
<Input
type="password"
autoFocus
value={unlockPass}
placeholder="Passphrase"
onChange={(e) => { setUnlockPass(e.target.value); setUnlockErr(''); }}
onKeyDown={(e) => { if (e.key === 'Enter' && unlockPass) doUnlock(); }}
className="mb-2"
/>
{unlockErr && <div className="text-xs text-destructive mb-2">{unlockErr}</div>}
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => { setUnlockOpen(false); setUnlockPass(''); setUnlockErr(''); }}>Later</Button>
<Button size="sm" disabled={!unlockPass || unlockBusy} onClick={doUnlock}>
{unlockBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />} Unlock
</Button>
</div>
</div>
</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-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[660px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
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, then Start/End at right. CQ/ITU zones moved
to the Info (F2) tab. */}
<div className="flex gap-2 items-end">
{callsignBlock}
{rstTxBlock}
{rstRxBlock}
{flagBlock}
<div className="ml-auto flex gap-2">
{dateBlock}
{startBlock}
{endBlock}
</div>
</div>
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
its right edge lines up with that column below; QTH grows to fill.
gap-4 matches Row 3 so QTH's left edge aligns with Comment/Note. */}
<div className="flex gap-4 items-end">
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</div>
{qthBlock}
{gridBlock}
{alertLedBlock}
</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 && (
// relative + absolute inner: the panel's content can't grow the row, so
// the row height is set by the ENTRY STRIP. A taller tab (Awards/F3) then
// scrolls inside this fixed height instead of pushing everything down.
<div className="w-[560px] shrink-0 min-h-0 relative">
<div className="absolute inset-0 flex flex-col min-h-0">
<DetailsPanel
callsign={callsign}
prefix={prefix}
operatorGrid={station.my_grid}
remoteGrid={grid}
details={details}
onChange={updateDetails}
wb={wb}
wbBusy={wbBusy}
band={band}
mode={mode}
bands={bands}
tab={detailTab}
onTab={setDetailTab}
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
/>
</div>
</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 && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled)) && (
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
{chatShown && (
// relative + absolute inner: the chat takes the row height (set by the
// entry strip) WITHOUT its message list growing the row, like the
// Stats panel. The list scrolls inside this fixed height.
<div className="w-[280px] shrink-0 min-h-0 relative">
<div className="absolute inset-0 flex flex-col min-h-0">
<ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
onSend={chatSend} onClose={() => setChatOpen(false)} />
</div>
</div>
)}
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
rotator is configured or a DX bearing exists. */}
{showRotor && (rotatorHeading.enabled || dxPath) && (
<div className="w-[186px] shrink-0 min-h-0">
<RotorCompass
bearing={dxPath?.bearingShort ?? null}
headings={beamHeadings}
boomHeading={boomHeading}
pattern={ubPattern}
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
/>
</div>
)}
{showAntGenius && agEnabled && (
<div className="w-[230px] shrink-0 min-h-0">
<AntGeniusPanel
status={agStatus}
onActivate={agActivate}
onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }}
band={band}
/>
</div>
)}
{dvkEnabled && (
<div className="w-[264px] shrink-0 min-h-0">
<DvkPanel
messages={dvkMsgs}
status={dvkStat}
onPlay={dvkPlay}
onStop={() => DVKStop()}
onClose={() => setDvkEnabled(false)}
/>
</div>
)}
{wkEnabled && (
<div className="w-[380px] shrink-0 min-h-0">
<WinkeyerPanel
status={cwSource === 'icom'
? { connected: catState.backend === 'icom' && catState.connected, busy: false, wpm: wkWpm, version: 0, port: 'CI-V' }
: wkStatus}
ports={wkPorts}
port={wkPort}
wpm={wkWpm}
macros={wkMacros}
sent={wkSent}
source={cwSource}
breakIn={icomBreakIn}
onSetBreakIn={setBreakIn}
onSelectPort={wkSelectPort}
onRefreshPorts={reloadWkPorts}
onConnect={() => WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))}
onDisconnect={() => WinkeyerDisconnect().catch(() => {})}
onSetSpeed={(w) => {
setWkWpm(w); saveWk({ wpm: w });
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
else WinkeyerSetSpeed(w).catch(() => {});
}}
onSend={wkSend}
onSendMacro={wkSendMacro}
onStop={() => { stopAutoCall(); if (cwSource === 'icom') IcomStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); }}
onClose={() => wkSetEnabled(false)}
sendOnType={wkSendOnType}
onToggleSendOnType={wkToggleSendOnType}
onSendRaw={wkSendRaw}
onBackspace={wkBackspace}
autoCall={wkAutoCall}
autoCallSecs={wkAutoCallSecs}
onToggleAutoCall={wkToggleAutoCall}
onSetAutoCallSecs={wkSetAutoCallSecs}
/>
</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 */}
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
{cwOn && (
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-success-border/70 bg-success-muted/60 px-2 py-1.5 text-xs">
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-success' : 'text-muted-foreground')} />
{/* Input-level meter — if this stays flat with a strong signal, the RX
audio device is wrong/silent rather than a decode problem. */}
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={`Audio level ${Math.round(cwStatus.level * 100)}%`}>
<div className="h-full bg-success transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
</div>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
</span>
{/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
<input
type="number"
value={cwPitch}
onChange={(e) => setCwPitch(e.target.value)}
placeholder="auto"
title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search."
className="shrink-0 w-14 h-5 rounded border border-success-border/70 bg-card/60 px-1 text-[10px] font-mono text-center outline-none"
/>
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
text (see cwScrollRef effect) so the latest stays in view. */}
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-none flex items-center">
{cwText.trim() === '' ? (
<span className="text-muted-foreground italic">listening</span>
) : (
<div className="inline-flex items-center whitespace-nowrap">
{cwText.trim().split(/\s+/).map((tok, i) => (
<button
key={i}
type="button"
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-success-muted/70"
title="Use as callsign"
onClick={() => onCallsignInput(tok, { force: true })}
>
{tok}
</button>
))}
</div>
)}
</div>
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title="Clear" onClick={() => setCwText('')}>
<Eraser className="size-3.5" />
</button>
</div>
)}
{/* ===== LOWER: tabbed table / cluster / 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-[300px_1fr]' : 'grid-cols-[1fr_300px]') : '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">{t('tab.main')}</TabsTrigger>
<TabsTrigger value="recent">
{t('tab.recent')}
</TabsTrigger>
<TabsTrigger value="cluster">{t('tab.cluster')}</TabsTrigger>
<TabsTrigger value="worked">
{t('tab.worked')}
{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">{t('tab.awards')}</TabsTrigger>
<TabsTrigger value="bandmap">{t('tab.bandmap')}</TabsTrigger>
{contestTabEnabled && (
<TabsTrigger value="contest" className={cn('gap-1.5', contest.active && 'text-warning data-[state=active]:text-warning')}>
{t('tab.contest')}
{contest.active && <span className="inline-block size-1.5 rounded-full bg-warning animate-pulse" />}
<span
role="button"
aria-label="Close Contest"
title={t('dup.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(); setContestTabEnabled(false); setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{netEnabled && (
<TabsTrigger value="net" className="gap-1.5">
{t('tab.net')}
<span
role="button"
aria-label="Close Net"
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(); setNetEnabled(false); setActiveTab((t) => (t === 'net' ? 'recent' : t)); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
{qslTabOpen && (
<TabsTrigger value="qsl" className="gap-1.5">
QSL Manager
<span
role="button"
aria-label="Close QSL Manager"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeQslTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
</TabsList>
<TabsContent value="recent" className="mt-0 flex flex-col min-h-0 flex-1">
<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-warning-muted border-warning-border text-warning-muted-foreground'
: 'bg-success-muted border-success-border text-success-muted-foreground',
)}>
<div className="flex items-center gap-3 flex-wrap">
<strong>Import complete.</strong>
<Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{importResult.imported} imported</Badge>
{importResult.updated > 0 && (
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.updated} updated</Badge>
)}
{importResult.duplicates > 0 && (
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.duplicates} duplicates</Badge>
)}
<Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{importResult.skipped} skipped</Badge>
<Badge variant="outline" className="bg-card/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={qsosWithAwards as any}
total={total}
awardCols={awardCols}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ}
onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo}
onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit}
onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF}
onExportCabrilloSelected={exportSelectedCabrillo}
onExportCabrilloFiltered={exportFilteredCabrillo}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
/>
<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-warning">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-success-muted text-success-muted-foreground border-success-border' :
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
'bg-muted text-muted-foreground border-border',
)}
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
>
{isMaster && <span className="text-warning" 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>
{clusterFiltersToggleBtn}
</div>
{/* Filters moved to the right-side panel (see below). */}
{(() => {
// Filtered + grouped spots (shared with the Main-view cluster
// pane). All the filter state lives in the right-side panel.
const rendered = clusterRenderedRows;
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={handleSpotClick}
/>
);
})()}
{/* 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 */}
{/* Right-side filter panel (Log4OM style) — shared with the
Main-view cluster pane; toggle hides it in both places. */}
{clusterShowFilters && renderClusterFilters()}
</TabsContent>
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
</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">
{/* Two configurable panes (per-profile, Settings → Main view).
Each side shows one of: great-circle map, locator map, cluster
or worked-before. */}
<div className="grid grid-cols-2 grid-rows-1 gap-2 h-full min-h-0 p-2">
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
</div>
</TabsContent>
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
<AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)} />
</TabsContent>
{contestTabEnabled && (
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
<ContestPanel session={contest} onChange={updateContest} />
</TabsContent>
)}
{/* FlexRadio SmartSDR-style control panel — only present when the CAT
backend is a FlexRadio. */}
{catState.backend === 'flex' && (
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</TabsContent>
)}
{/* Icom CI-V receive-DSP control panel — only when the CAT backend
is an Icom. */}
{catState.backend === 'icom' && (
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</TabsContent>
)}
{/* Band Map: several bands shown side-by-side (panadapter-style
strips). Pick bands with the chips; each strip is clickable to
tune the rig. */}
{netEnabled && (
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
</TabsContent>
)}
<TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
<span className="text-xs text-muted-foreground mr-1">{t('bmp.bandsLabel')}</span>
{bands.map((b) => {
const on = bandMapBands.includes(b);
return (
<button key={b} type="button" onClick={() => toggleBandMapBand(b)}
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
{b}
</button>
);
})}
<span className="ml-auto flex items-center gap-1">
<button type="button" onClick={toggleBandMapHideFt} title={t('bmp.hideFtTitle')}
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
bandMapHideFt ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
{t('bmp.hideFt')}
</button>
<button type="button" onClick={toggleBandMapFit} title={t('bmp.fitTitle')}
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
bandMapFit ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
{t('bmp.fitBand')}
</button>
</span>
</div>
<div className="flex-1 min-h-0 flex gap-2 p-2 overflow-x-auto">
{bandMapBands.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Pick one or more bands above to show their band maps side by side.
</div>
) : bandMapBands.map((b) => (
<div key={b} className="w-[260px] shrink-0 min-h-0 border border-border rounded-lg overflow-hidden flex flex-col">
<BandMap
band={b}
spots={spots.filter((s) => s.band === b)}
spotStatus={spotStatus}
currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={handleSpotClick}
onClose={() => toggleBandMapBand(b)}
hideDigital={bandMapHideFt}
fitToBand={bandMapFit}
/>
</div>
))}
</div>
</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={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={handleSpotClick}
onClose={() => setBandMapShown(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-success' : '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" />
{dbConn && (
<button
type="button"
onClick={() => { setSettingsSection('database'); setShowSettings(true); }}
title={dbConn.backend === 'mysql' ? `Shared MySQL logbook — ${dbConn.label}` : `Local SQLite logbook — ${dbConn.label}`}
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground max-w-[340px]"
>
<Database className={cn('size-3 shrink-0', dbConn.backend === 'mysql' ? 'text-success' : 'text-muted-foreground')} />
<span className="font-mono truncate">{dbConn.label}</span>
</button>
)}
</footer>
);
})()}
{editingQSO && (
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} countries={countries} bands={bands} modes={modes} />
)}
<BulkEditModal
open={bulkEditOpen}
ids={bulkEditIds}
onClose={() => setBulkEditOpen(false)}
onApplied={(n) => { afterBulkUpdate(n, 'in bulk'); }}
/>
<SendSpotModal
open={showSpotModal}
onClose={() => setShowSpotModal(false)}
// Callsign: the QSO-entry call, else the last logged QSO.
defaultCall={callsign.trim() || qsos[0]?.callsign || ''}
// Freq (0.1 kHz): the LIVE CAT frequency in Hz when connected — NOT the
// freqMhz display string, which the manual-edit freeze / field locks can
// leave stale (that dropped the sub-kHz: on 14134.5 the frozen "14.134"
// string spotted 14134). Fall back to the entry field, then the last QSO.
defaultFreqKHz={
catState.connected && (catState.freq_hz ?? 0) > 0
? Math.round(((catState.freq_hz ?? 0) / 1000) * 10) / 10
: 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(); }}
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
flexAvailable={catState.backend === 'flex'}
icomAvailable={catState.backend === 'icom'}
/>
)}
{alertsOpen && (
<AlertsModal onClose={() => setAlertsOpen(false)} bands={bands} modes={modes} countries={countries} />
)}
{showDuplicates && (
<DuplicatesModal onClose={() => setShowDuplicates(false)} onDeleted={() => refresh()} />
)}
<AutoEQSL
onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
onError={(msg) => showToast(msg)}
/>
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
<SendEQSLModal
open={eqslQsoId !== null}
qsoId={eqslQsoId}
onClose={() => setEqslQsoId(null)}
onOpenDesigner={() => setQslDesignerOpen(true)}
/>
{deletingIds.length > 0 && (() => {
const single = deletingIds.length === 1 ? qsos.find((x) => x.id === deletingIds[0]) : null;
return (
<ConfirmDialog
title={deletingIds.length > 1 ? `Delete ${deletingIds.length} QSOs?` : 'Delete QSO?'}
message={single
? `This will permanently delete the QSO with ${single.callsign} on ${fmtDateUTC(single.qso_date)} (${single.band} ${single.mode}). This cannot be undone.`
: `This will permanently delete ${deletingIds.length} selected QSO${deletingIds.length > 1 ? 's' : ''}. This cannot be undone.`}
confirmLabel="Delete"
danger
onConfirm={confirmDelete}
onCancel={() => setDeletingIds([])}
/>
);
})()}
{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. OpsLog writes ADIF 3.1.7.
</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 only</div>
<div className="text-xs text-muted-foreground mt-0.5">
Only fields defined in the ADIF 3.1.7 spec portable to other loggers (Log4OM, N1MM, LoTW).
Application-specific <span className="font-mono">APP_*</span> and any non-standard / vendor 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">All fields (OpsLog round-trip)</div>
<div className="text-xs text-muted-foreground mt-0.5">
Every field including application-specific <span className="font-mono">APP_*</span> and vendor tags
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). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF Reunion, TO2A 2012 French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use <strong>Update duplicates</strong> to re-fix QSOs already in your log.
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer pt-1">
<Checkbox
checked={importApplyStation}
onCheckedChange={(c) => setImportApplyStation(!!c)}
className="mt-0.5"
/>
<span>
Fill my station fields from my profile
<span className="block text-xs text-muted-foreground mt-0.5">
Backfill <strong>empty</strong> MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power) plus <strong>Operator</strong> and <strong>Owner callsign</strong> from your active profile. Existing values are kept. Only <strong>STATION_CALLSIGN</strong> is left untouched so a mixed-call log isn't re-routed. Enable when importing <em>your own</em> log.
</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>
);
}