Files
OpsLog/frontend/src/App.tsx
T

3955 lines
195 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, 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,
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus, CheckForUpdate,
WorkedBefore,
SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading,
GetDBConnectionInfo, GetLogbookRevision,
GetUltrabeamStatus, SetUltrabeamDirection,
OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
GetCATSettings,
OperatingDefaultForBand,
LogUDPLoggedADIF,
ListCountries,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart,
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 { 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 { BulkEditModal } from '@/components/BulkEditModal';
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: undefined, stx: undefined,
email: '',
award_refs: '',
};
function fmtDateUTC(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return s;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
function fmtFreq(hz?: number): string {
if (!hz) return '';
return (hz / 1_000_000).toFixed(4);
}
// cleanSpotter / inferSpotMode / spotStatusKey live in lib/spot.ts so
// the BandMap component reads from the same canonical source — keeps
// "CW spot looks like CW everywhere" honest.
function fmtHMSUTC(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
}
// parseHMSUTC parses "HH:MM" or "HH:MM:SS" and returns a Date with that
// UTC time on the same UTC day as base. Tolerates "HHMM" / "HHMMSS" too
// (no separators) so the user can type fast. Falls back to base on bad input.
function parseHMSUTC(s: string, base: Date): Date {
const clean = s.replace(/[^0-9:]/g, '');
let h = 0, m = 0, sec = 0;
if (clean.includes(':')) {
const parts = clean.split(':');
h = parseInt(parts[0] ?? '0', 10) || 0;
m = parseInt(parts[1] ?? '0', 10) || 0;
sec = parseInt(parts[2] ?? '0', 10) || 0;
} else if (clean.length >= 4) {
h = parseInt(clean.slice(0, 2), 10) || 0;
m = parseInt(clean.slice(2, 4), 10) || 0;
sec = clean.length >= 6 ? (parseInt(clean.slice(4, 6), 10) || 0) : 0;
} else {
return base;
}
if (h > 23 || m > 59 || sec > 59) return base;
const d = new Date(base);
d.setUTCHours(h, m, sec, 0);
return d;
}
// fmtFreqDots formats a MHz string for display as MHz.kHz.Hz with dot
// separators (14.266500 → "14.266.500"). Pads the fractional part to 6
// digits so partial inputs ("14.21") still render as "14.210.000".
function fmtFreqDots(mhzStr: string): string {
if (!mhzStr) return '';
const [intPart, fracRaw = ''] = mhzStr.split('.');
const frac = (fracRaw + '000000').slice(0, 6);
return `${intPart}.${frac.slice(0, 3)}.${frac.slice(3, 6)}`;
}
// shortCatError condenses a backend error into a few words for the topbar
// pill. The full message stays in the tooltip. Recognises the common cases
// (OmniRig not installed, not registered) and otherwise truncates.
function shortCatError(err?: string): string {
if (!err) return '';
const e = err.toLowerCase();
if (e.includes('not registered') || e.includes('not available')) return 'OmniRig not found';
if (e.includes('not connected')) return 'not connected';
if (e.includes('coinitialize')) return 'COM error';
return err.length > 24 ? err.slice(0, 22) + '…' : err;
}
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' if outside
// every known allocation (used to auto-fill the band when the freq changes).
function bandForMHz(mhz: number): string {
if (!mhz || isNaN(mhz)) return '';
const plan: [number, number, string][] = [
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [1240, 1300, '23cm'],
];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return '';
}
// rstCategory buckets a mode into the report family used for its RST list.
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
function rstCategory(mode: string): keyof RSTLists {
const m = (mode || '').toUpperCase();
const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
if (digital.includes(m)) return 'digital';
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
return 'phone';
}
// rstOptions returns the valid report choices for a mode from the user's
// editable lists (Settings → Modes), with a tiny fallback before they load.
function rstOptions(mode: string, lists: RSTLists): string[] {
const cat = rstCategory(mode);
const l = lists[cat];
if (l && l.length) return l;
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
}
function computePrefix(call: string): string {
if (!call) return '';
const c = call.trim().toUpperCase().split('/')[0];
let lastDigit = -1;
for (let i = 0; i < c.length; i++) {
if (c[i] >= '0' && c[i] <= '9') lastDigit = i;
}
return lastDigit >= 0 ? c.slice(0, lastDigit + 1) : c;
}
export default function App() {
// === Lists from settings (fallback for first paint) ===
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
const [modePresets, setModePresets] = useState<ModePreset[]>([]);
// === Entry ===
const [callsign, setCallsign] = useState('');
// Ref to the callsign input so ESC can snap focus back to it.
const callsignRef = useRef<HTMLInputElement>(null);
// QSO start time — frozen when the operator starts typing the callsign,
// logged as ADIF QSO_DATE. End time = save click (logged as QSO_DATE_OFF).
const [qsoStartedAt, setQsoStartedAt] = useState<Date | null>(null);
// Frozen end time used when the End-UTC field is locked (backdated QSO);
// null means the field tracks the live clock.
const [qsoEndedAt, setQsoEndedAt] = useState<Date | null>(null);
// Local string buffers for the time inputs while the user is editing.
// Parsing only on blur — otherwise every keystroke would re-format the
// value and React would move the cursor back to the end of the field.
const [startInputStr, setStartInputStr] = useState('');
const [endInputStr, setEndInputStr] = useState('');
const [startFocused, setStartFocused] = useState(false);
const [endFocused, setEndFocused] = useState(false);
const [freqFocused, setFreqFocused] = useState(false);
// Per-field locks — like Log4OM's padlocks. When locked, CAT updates skip
// that field and the time fields become editable so the user can log a
// QSO from the past while the radio is parked elsewhere.
type LockKey = 'band' | 'mode' | 'freq' | 'start' | 'end';
const [locks, setLocks] = useState<Record<LockKey, boolean>>({
band: false, mode: false, freq: false, start: false, end: false,
});
const locksRef = useRef(locks);
useEffect(() => { locksRef.current = locks; }, [locks]);
const toggleLock = (k: LockKey) => {
setLocks((s) => {
const wasLocked = s[k];
const next = { ...s, [k]: !wasLocked };
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-amber-600 hover:text-amber-700' : 'text-muted-foreground/40 hover:text-muted-foreground',
)}
>
<Icon className="size-3" />
</button>
);
}
const [band, setBand] = useState('20m');
const [mode, setMode] = useState('SSB');
const [freqMhz, setFreqMhz] = useState('');
// RX freq for split — only set/shown when the rig is in split mode.
const [rxFreqMhz, setRxFreqMhz] = useState('');
// RX band — follows the TX band by default; only differs for cross-band work.
const [bandRx, setBandRx] = useState('20m');
const [countries, setCountries] = useState<string[]>([]);
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
const [rstSent, setRstSent] = useState('59');
const [rstRcvd, setRstRcvd] = useState('59');
const [grid, setGrid] = useState('');
const [name, setName] = useState('');
const [qth, setQth] = useState('');
const [country, setCountry] = useState('');
const [comment, setComment] = useState('');
const [note, setNote] = useState('');
// Compact (popout) mode: shrinks the window + always-on-top so the entry
// strip can sit on top of WSJT-X / JT-Alert / the cluster.
const [compact, setCompact] = useState(false);
function toggleCompact() {
const next = !compact;
setCompact(next);
SetCompactMode(next);
}
// CAT — receives live rig state via Wails events.
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
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.
function onBandUserChange(v: string) {
setBand(v);
noteManualEdit();
if (catState.enabled && catState.connected) {
const hz = qsyFreqHz(v, mode);
if (hz > 0) SetCATFrequency(hz).catch(() => {});
}
}
function onModeUserChange(v: string) {
setMode(v);
applyModePreset(v);
noteManualEdit();
if (catState.enabled && catState.connected) {
SetCATMode(v).catch(() => {});
}
}
const userEditedRef = useRef<Set<string>>(new Set());
const lastLookedUpRef = useRef<string>('');
// Tracks the call we last auto-switched to the Worked-before tab for, so we
// don't keep yanking the tab on every wb refresh of the same callsign.
const lastWbFocusRef = useRef<string>('');
const rstUserEditedRef = useRef<boolean>(false);
const [details, setDetails] = useState<DetailsState>(emptyDetails);
const updateDetails = useCallback((patch: Partial<DetailsState>) => {
setDetails((d) => ({ ...d, ...patch }));
}, []);
// Auto-fill MY_RIG / MY_ANTENNA from the operating conditions tree
// whenever the band changes. The backend resolves the "default antenna
// for this band" within the active profile and returns the (rig,
// antenna) tuple. Empty result → we DO clear the fields so leftover
// values from a previous band don't get logged against the wrong gear.
useEffect(() => {
if (!band) return;
let cancelled = false;
OperatingDefaultForBand(band).then((d) => {
if (cancelled) return;
setDetails((cur) => ({
...cur,
my_rig: d?.station_name || '',
my_antenna: d?.antenna_name || '',
tx_pwr: d?.tx_pwr ?? cur.tx_pwr,
}));
}).catch(() => {});
return () => { cancelled = true; };
}, [band]);
const prefix = useMemo(() => computePrefix(callsign), [callsign]);
// === 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.
const [toast, setToast] = useState('');
const showToast = useCallback((msg: string) => {
setToast(msg);
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500);
}, []);
// Error banners auto-dismiss after a few seconds (longer than toasts since
// they may be multi-line). The X button still closes them immediately.
useEffect(() => {
if (!error) return;
const t = window.setTimeout(() => setError(''), 6000);
return () => window.clearTimeout(t);
}, [error]);
// True while the QSO recorder is capturing the current contact (set when we
// leave the callsign field, cleared on log/cancel). Drives the REC badge.
const [recording, setRecording] = useState(false);
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
// second while a recording is in progress.
const [recSeconds, setRecSeconds] = useState(0);
// 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(() => {});
};
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]);
// === 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
// 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(() => { wkActiveRef.current = wkEnabled && wkStatus.connected; }, [wkEnabled, wkStatus.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; });
}
const [dvkEnabled, setDvkEnabled] = useState(false);
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
const dvkActiveRef = useRef(false);
const dvkPlayingRef = useRef(false);
const dvkPlayRef = useRef<(slot: number) => void>(() => {});
useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]);
useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]);
useEffect(() => {
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
return () => { off?.(); };
}, []);
const reloadDvk = useCallback(() => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); }, []);
// Load messages + status whenever the keyer is switched on.
useEffect(() => {
if (!dvkEnabled) return;
reloadDvk();
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
}, [dvkEnabled, reloadDvk]);
const dvkPlay = useCallback((slot: number) => { DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e))); }, []);
useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]);
// Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch
// it from the keyboard without clashing with the F1-F12 keyer macros).
type DetailTab = 'stats' | 'info' | 'awards' | 'my' | 'extended';
const [detailTab, setDetailTab] = useState<DetailTab>('stats');
const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]);
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
const [spots, setSpots] = useState<ClusterSpot[]>([]);
const SPOTS_CAP = 1000;
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
const [clusterGroup, setClusterGroup] = useState(true);
const [clusterCmd, setClusterCmd] = useState('');
// Multi-band filter: empty set = all bands. The user toggles chips.
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
// Lock-to-entry: when on, the band filter follows the entry's current
// band and the mode filter follows the entry's current mode.
const [clusterLockBand, setClusterLockBand] = useState(false);
const [clusterLockMode, setClusterLockMode] = useState(false);
// Status filter chips. Empty set = show every status (including
// already-worked). Otherwise only matching spots pass.
type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
// Mode filter chips. Empty set = show every mode. Categories map the
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
const [clusterSearch, setClusterSearch] = useState('');
// 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;
});
}, []);
// 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';
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';
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 [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 }[]>([]);
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(() => {});
}, []);
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]);
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]);
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 [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]);
// 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); };
}, []);
// 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;
} 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);
if (catState.connected) {
tuneRigCAT(s.freq_hz, m);
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
}
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
}, []);
// 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.
const applyUdpCall = (raw: string): 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 (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) => {
// External app moved to a new station → fresh recording for the new target.
if (applyUdpCall(p?.call)) restartRecordingForNewTarget(String(p?.call ?? ''));
});
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? ''));
});
// 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).
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
const call = String(p?.call ?? '');
if (applyUdpCall(call)) 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);
} 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();
});
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 != null ? String(details.srx) : '', CONT_TX: details.stx != null ? String(details.stx) : '',
};
let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? '');
out = out.replace(/\*/g, myCall).replace(/!/g, his);
// <n> (2..9): repeat the whole resolved string n times.
const rep = out.match(/<([2-9])>/);
if (rep) {
const n = parseInt(rep[1], 10);
const base = out.replace(/<[2-9]>/g, '').replace(/\s+/g, ' ').trim();
out = Array(n).fill(base).join(' ');
}
return out.replace(/\s+/g, ' ').trim();
}
async function wkSend(rawText: string) {
setWkSent('');
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
await WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e)));
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
// finished sending — wait for the busy flag to rise then fall, so the QSO
// isn't logged (and the form cleared) while the CW is still going out.
if (!doLog) return;
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish
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);
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
for (let k = 0; k < 2400 && wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤120s to finish
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;
const payload: any = {
callsign: callsign.trim().toUpperCase(),
qso_date: start.toISOString(),
qso_date_off: end.toISOString(),
band, band_rx: bandRx, mode, freq_hz: freqHz, freq_rx_hz: rxFreqHz,
rst_sent: rstSent, rst_rcvd: rstRcvd,
grid: grid.trim().toUpperCase(),
name, qth, country, comment, notes: note,
state: details.state, cnty: details.cnty, address: details.address,
lat: details.lat, lon: details.lon,
dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz, cont: details.cont || undefined,
qsl_msg: details.qsl_msg, qsl_via: details.qsl_via,
ant_az: details.ant_az, ant_el: details.ant_el, ant_path: details.ant_path,
prop_mode: details.prop_mode,
my_rig: details.my_rig, my_antenna: details.my_antenna,
tx_pwr: details.tx_pwr,
sat_name: details.sat_name, sat_mode: details.sat_mode,
contest_id: details.contest_id,
srx: details.srx, stx: details.stx,
email: details.email,
};
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
await AddQSO(payload);
resetEntry(); // 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('');
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)); }
}
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);
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);
// 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 (v.trim() !== '') 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: 'File', items: [
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
{ type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
{ type: 'separator' },
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
{ type: 'separator' },
{ type: 'item', label: 'Exit', action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true },
]},
{ name: 'edit', label: 'Edit', items: [
{ type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
{ type: 'item', label: 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: 'Preferences…', action: 'edit.prefs' },
]},
{ name: 'view', label: 'View', items: [
{ type: 'item', label: 'Refresh', action: 'view.refresh', shortcut: 'F5' },
{ type: 'item', label: 'Clear filters', action: 'view.clearfilters' },
]},
{ name: 'tools', label: 'Tools', items: [
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
{ type: 'item', label: 'QSL Card Designer…', action: 'tools.qsldesigner' },
{ type: 'separator' },
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
{ type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
{ 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: 'Help', items: [
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled]);
function handleMenu(action: string) {
switch (action) {
case 'file.import': importAdif(); break;
case 'file.export': exportAdif(); break;
case 'file.deleteall': setShowDeleteAll(true); break;
case 'view.refresh': refresh(); break;
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
case 'edit.delete': 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.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="mb-1 flex items-center gap-2 h-3.5">
Callsign
{lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up</Badge>}
{!lookupBusy && lookupResult && (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider" variant="outline">
<CheckCircle2 className="size-2.5 mr-1" />{lookupResult.source}
</Badge>
)}
{!lookupBusy && !lookupResult && lookupError && (
<Badge variant="destructive" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider">{lookupError}</Badge>
)}
</Label>
<div className="relative">
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap pointer-events-none">
<span className="size-2 rounded-full bg-red-600 animate-pulse" />
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
</span>
)}
<Input
ref={callsignRef}
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
value={callsign}
onChange={(e) => onCallsignInput(e.target.value)}
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">RST tx</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">RST rx</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-emerald-700">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-emerald-700">Start UTC <LockBtn k="start" title="start time" /></Label>
<Input
readOnly={!locks.start}
tabIndex={locks.start ? 0 : -1}
value={startFocused ? startInputStr : (qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : (locks.start ? '' : '—'))}
onFocus={() => { setStartInputStr(qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : ''); setStartFocused(true); }}
onBlur={() => {
setStartFocused(false);
if (startInputStr.trim() === '') { if (locks.start) setQsoStartedAt(null); return; }
setQsoStartedAt(parseHMSUTC(startInputStr, qsoStartedAt ?? new Date()));
}}
onChange={(e) => setStartInputStr(e.target.value)}
placeholder="HH:MM:SS"
className={cn('font-mono', locks.start ? '' : 'bg-muted/40 cursor-default')}
/>
</div>
);
const endBlock = (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-rose-700">End UTC <LockBtn k="end" title="end time" /></Label>
<Input
readOnly={!locks.end}
tabIndex={locks.end ? 0 : -1}
value={endFocused ? endInputStr : (
locks.end ? (qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : '')
// 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">Name</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</div>
);
const qthBlock = (
<div className="flex flex-col flex-[0.55] min-w-[70px]"><Label className="mb-1 h-3.5">QTH</Label>
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
</div>
);
const gridBlock = (
<div className="flex flex-col w-28 shrink-0"><Label className="mb-1 h-3.5">Grid</Label>
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</div>
);
// Compact-strip Country (stacked label) + a narrow Comment.
const countryBlockSm = (
<div className="flex flex-col w-36">
<Label className="mb-1 h-3.5 flex items-center gap-1.5">
Country
{flagURL(details.dxcc) && (
<img src={flagURL(details.dxcc)} alt="" className="h-3 rounded-[2px] border border-border/50 shadow-sm"
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
)}
</Label>
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
</div>
);
const commentSm = (
<div className="flex flex-col w-40"><Label className="mb-1 h-3.5">Comment</Label>
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
</div>
);
// Inline-label variants (label to the LEFT of the control, Log4OM-style) —
// used in the full layout to save vertical height.
const bandRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label>
<div className="flex-1 min-w-0">
<Select value={band} onValueChange={onBandUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
);
const modeRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">Mode <LockBtn k="mode" title="mode" /></Label>
<div className="flex-1 min-w-0">
<Select value={mode} onValueChange={onModeUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
);
const countryRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">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">{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} <LockBtn k="freq" title="frequency" /></Label>
<Input
tabIndex={-1}
className="font-mono"
value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
placeholder="14.250"
onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)}
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
/>
</div>
);
const rxFreqBlock = (
<div className="flex flex-col w-32">
<Label className={cn('mb-1 h-3.5', catState.split && 'text-rose-600')}>RX Freq (MHz)</Label>
<Input
tabIndex={-1}
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
placeholder="14.255"
onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)}
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
className={cn('font-mono', catState.split && 'bg-rose-50/40 border-rose-200 focus:bg-card')}
/>
</div>
);
const bandRxBlock = (
<div className="flex flex-col w-28"><Label className="mb-1 h-3.5">RX Band</Label>
<Select value={bandRx} onValueChange={setBandRx}>
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div>
);
// Single-line Comment/Note for the full layout (stacked in the right
// column). No flex-1 so they stay one row tall.
const commentLine = (
<div className="flex flex-col"><Label className="mb-1 h-3.5">Comment</Label>
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
</div>
);
const noteLine = (
<div className="flex flex-col"><Label className="mb-1 h-3.5">Note</Label>
<Input value={note} onChange={(e) => setNote(e.target.value)} />
</div>
);
const logButtons = (
<div className="flex flex-col">
<Label className="mb-1 h-3.5">&nbsp;</Label>
<div className="flex gap-2">
{clusterServerStatuses.some((s) => s.state === 'connected') && (
<Button type="button" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8" title="Send a DX spot to the master cluster">
<Satellite className="size-3.5" />
Spot
</Button>
)}
<Button type="button" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8"
title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
<Eraser className="size-3.5" />
Clear
</Button>
<Button onClick={save} disabled={saving} className="h-8">
<Send className="size-3.5" />
{saving ? '…' : 'Log QSO'}
</Button>
</div>
</div>
);
// Compact strip: Log only — the Spot dialog would be clipped by the tiny
// always-on-top window, so it's reachable only in normal mode.
const logButtonCompact = (
<div className="flex flex-col">
<Label className="mb-1 h-3.5">&nbsp;</Label>
<Button onClick={save} disabled={saving} className="h-8">
<Send className="size-3.5" />
{saving ? '…' : 'Log QSO'}
</Button>
</div>
);
// 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-amber-100 text-amber-800 border-amber-300' : '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-amber-100 text-amber-800 border-amber-300' : '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-rose-100 text-rose-800 border-rose-300' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
]).map((s) => {
const on = clusterStatusFilter.has(s.k);
return (
<button key={s.k} type="button"
onClick={() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
{s.label}
</button>
);
})}
</div>
</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-sky-100 text-sky-800 border-sky-300' },
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
]).map((s) => {
const on = clusterModeFilter.has(s.k);
return (
<button key={s.k} type="button"
onClick={() => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
{s.label}
</button>
);
})}
</div>
</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}
/>
);
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)} />
</div>
);
case 'flex':
return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<FlexPanel />
</div>
);
}
};
return (
<div className="flex flex-col h-screen bg-background">
<ShutdownProgress />
{/* ===== TOPBAR ===== */}
{compact ? (
// Minimal compact topbar — brand + freq + toggle. Saves vertical space
// so the single-row entry strip fits in a ~140px tall window.
<header className="flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
<div className="flex items-center gap-1.5">
<div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
<span className="font-bold text-xs tracking-tight">OpsLog</span>
</div>
<div className="flex items-baseline gap-1.5 font-mono ml-2">
<span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
<span className="text-[9px] text-muted-foreground">MHz</span>
<Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge>
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono text-[9px] py-0" variant="outline">{mode}</Badge>
</div>
<span className="ml-2 font-mono text-[10px] text-muted-foreground">{utcNow}<span className="text-[9px]">Z</span></span>
<div className="flex-1" />
{station.callsign && (
<span className="font-mono text-[11px] font-bold text-primary mr-2">{station.callsign}</span>
)}
<Button variant="outline" size="icon" className="size-6" onClick={toggleCompact} title="Exit compact mode">
<Maximize2 className="size-3" />
</Button>
</header>
) : (
<header className="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
<span className="font-bold text-[15px] tracking-tight">OpsLog</span>
<span className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
</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-emerald-300 bg-emerald-50 text-emerald-800 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-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><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-rose-600 font-semibold mr-1">RX</span>
{fmtFreqDots(rxFreqMhz)}
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground uppercase">MHz</span>
{catState.split && (
<Badge className="bg-amber-100 text-amber-800 border-amber-300 font-mono text-[10px] py-0" variant="outline">SPLIT</Badge>
)}
{/* Band & mode removed here — shown in the QSO entry strip below. */}
{catState.enabled && catState.backend === 'omnirig' && (
<div className="inline-flex rounded-md border border-border overflow-hidden text-[11px] font-sans font-semibold">
{[1, 2].map((n) => {
const active = (catState.rig_num || 1) === n;
return (
<button key={n} type="button" onClick={() => SwitchCATRig(n).catch(() => {})}
className={cn('px-2 py-0.5 transition-colors', active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}
title={`Use OmniRig Rig ${n}`}>R{n}</button>
);
})}
</div>
)}
<div className="w-px h-4 bg-border mx-2" />
{/* Bearing controls — three separate buttons so SP and LP are
both directly clickable, plus an always-visible Stop. The
old Shift/Ctrl shortcuts were not discoverable enough. */}
{(() => {
const p = 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-sky-300 bg-sky-100 overflow-hidden text-[11px] font-mono font-semibold">
<button
type="button"
disabled={disabled}
onClick={() => p && goto(p.bearingShort)}
title={p
? `Rotate short-path · ${Math.round(p.distanceShort).toLocaleString()} km`
: (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
disabled
? 'text-muted-foreground/60 cursor-not-allowed'
: 'text-sky-800 hover:bg-sky-200 cursor-pointer',
)}
>
<Compass className="size-3" />
{p ? `${Math.round(p.bearingShort)}°` : '—°'}
</button>
<button
type="button"
disabled={disabled}
onClick={() => p && goto(p.bearingLong)}
title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
className={cn(
'px-1.5 py-0.5 border-l border-sky-300 text-[10px] transition-colors',
disabled
? 'text-muted-foreground/60 cursor-not-allowed'
: 'text-sky-800 hover:bg-sky-200 cursor-pointer',
)}
>
LP {p ? `${Math.round(p.bearingLong)}°` : '—'}
</button>
<button
type="button"
onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))}
title="Stop rotation"
className="px-1.5 py-0.5 border-l border-sky-300 text-rose-700 hover:bg-rose-100 hover:text-rose-800 cursor-pointer transition-colors"
>
<Square className="size-2.5 fill-current" />
</button>
</div>
);
})()}
{/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
{ubStatus.enabled && (
<div className="inline-flex items-center rounded-full border border-emerald-300 bg-emerald-50 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-amber-500' : 'bg-emerald-500') : '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-emerald-600 text-white' : 'text-emerald-800 hover:bg-emerald-100',
!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-rose-300 bg-rose-100 text-rose-700'
: dvkEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: '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-rose-500 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-amber-300 bg-amber-100 text-amber-800'
: wkEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: '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-amber-500 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-emerald-400 bg-emerald-100 text-emerald-800'
: cwEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: '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-emerald-500 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-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Compass className="size-4" />
</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-white/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
)}
</button>
) : (
<Button variant="outline" size="sm" onClick={() => setShowSettings(true)}>
<Settings className="size-3.5" /> Set station
</Button>
)}
<Button
variant={showBandMap ? 'default' : 'outline'}
size="sm"
onClick={() => 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-emerald-600">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: wide Name + QTH + Grid across the full width. */}
<div className="flex gap-2 items-end">
{nameBlock}
{qthBlock}
{gridBlock}
</div>
{/* Row 3: tight left detail column (Band/Mode/Country) and
single-line Comment/Note on the right. */}
<div className="flex gap-4 items-start">
<div className="flex flex-col gap-1.5 w-[300px] shrink-0">
{bandRow}
{modeRow}
{countryRow}
</div>
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
{commentLine}
{noteLine}
</div>
</div>
{/* Bottom: TX freq, RX freq, RX band — plus the action buttons. */}
<div className="flex gap-2 items-end">
{freqBlock}
{rxFreqBlock}
{bandRxBlock}
<div className="ml-auto">{logButtons}</div>
</div>
</>
)}
</section>
{/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail
tabs, then reserved free space. Hidden in compact mode. */}
{!compact && (
// 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 && (wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath))) && (
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
{/* 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>
)}
{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={wkStatus}
ports={wkPorts}
port={wkPort}
wpm={wkWpm}
macros={wkMacros}
sent={wkSent}
onSelectPort={wkSelectPort}
onRefreshPorts={reloadWkPorts}
onConnect={() => WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))}
onDisconnect={() => WinkeyerDisconnect().catch(() => {})}
onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
onSend={wkSend}
onSendMacro={wkSendMacro}
onStop={() => { stopAutoCall(); 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 mb-1 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1 text-xs">
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : '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-emerald-500 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-emerald-300/70 bg-white/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-5">
{cwText.trim() === '' ? (
<span className="text-muted-foreground italic">listening</span>
) : (
<div className="inline-flex whitespace-nowrap">
{cwText.trim().split(/\s+/).map((tok, i) => (
<button
key={i}
type="button"
className="mr-1 shrink-0 rounded px-1 hover:bg-emerald-200/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">Main</TabsTrigger>
<TabsTrigger value="recent">
Recent QSOs
</TabsTrigger>
<TabsTrigger value="cluster">Cluster</TabsTrigger>
<TabsTrigger value="worked">
Worked before
{wb && wb.count > 0 && (
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{wb.count}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="awards">Awards</TabsTrigger>
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
{/* Not a tab — QRZ blocks embedding, so this opens the call's
QRZ.com page in the system browser. Styled like a trigger. */}
<button
type="button"
disabled={!callsign.trim()}
title={callsign.trim() ? `Open ${callsign.trim().toUpperCase()} on QRZ.com` : 'Enter a callsign first'}
onClick={() => {
const c = callsign.trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e) => setError(String(e?.message ?? e)));
}}
className="inline-flex items-center justify-center gap-1 whitespace-nowrap px-3 py-1.5 text-xs font-medium text-muted-foreground border-b-2 border-transparent transition-all hover:text-foreground disabled:pointer-events-none disabled:opacity-50 -mb-px"
>
QRZ.com
</button>
{qslTabOpen && (
<TabsTrigger value="qsl" className="gap-1.5">
QSL Manager
<span
role="button"
aria-label="Close QSL Manager"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeQslTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
</TabsList>
<TabsContent value="recent" className="mt-0 flex flex-col min-h-0 flex-1">
<div className="flex gap-2 p-2.5 border-b border-border/60">
<Input
className="flex-1"
placeholder="Search callsign…"
value={filterCallsign}
onChange={(e) => setFilterCallsign(e.target.value)}
/>
<Button variant="outline" size="sm" onClick={() => refresh()}>
<RefreshCw className="size-3.5" /> Refresh
</Button>
</div>
{importResult && (
<div className={cn(
'mx-2.5 mt-2 px-3 py-2 rounded-md text-xs border flex flex-col gap-1.5',
importResult.errors && importResult.errors.length > 0
? 'bg-amber-50 border-amber-300 text-amber-800'
: 'bg-emerald-50 border-emerald-300 text-emerald-800',
)}>
<div className="flex items-center gap-3 flex-wrap">
<strong>Import complete.</strong>
<Badge variant="outline" className="bg-white/60 font-mono text-emerald-700 border-emerald-300">{importResult.imported} imported</Badge>
{importResult.updated > 0 && (
<Badge variant="outline" className="bg-white/60 font-mono text-violet-700 border-violet-300">{importResult.updated} updated</Badge>
)}
{importResult.duplicates > 0 && (
<Badge variant="outline" className="bg-white/60 font-mono text-sky-700 border-sky-300">{importResult.duplicates} duplicates</Badge>
)}
<Badge variant="outline" className="bg-white/60 font-mono text-amber-700 border-amber-300">{importResult.skipped} skipped</Badge>
<Badge variant="outline" className="bg-white/60 font-mono">{importResult.total} total</Badge>
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
<button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
{importDupsOpen ? 'Hide' : 'Show'} duplicates
{importResult.duplicates > importResult.duplicate_samples.length
? ` (first ${importResult.duplicate_samples.length} of ${importResult.duplicates})`
: ''}
</button>
)}
{importResult.errors && importResult.errors.length > 0 && (
<button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}>
{importErrorsOpen ? 'Hide' : 'Show'} {importResult.errors.length} error{importResult.errors.length > 1 ? 's' : ''}
</button>
)}
<button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
</div>
{importDupsOpen && importResult.duplicate_samples && (
<ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
{importResult.duplicate_samples.map((d, i) => <li key={i}>{d}</li>)}
</ul>
)}
{importErrorsOpen && importResult.errors && (
<ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
{importResult.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
)}
</div>
)}
<RecentQSOsGrid
rows={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}
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-amber-700">Limit reached raise Max to see more.</span>
)}
<Label className="text-[11px] text-muted-foreground">Max</Label>
<Input
type="number"
min={1}
step={100}
className="w-24 h-7 font-mono text-xs"
value={qsoLimit}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n) && n > 0) setQsoLimit(Math.floor(n));
}}
/>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => setQsoLimit(Math.max(total, 1))}
title="Load the entire log"
disabled={total === 0}
>
All ({total})
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="cluster" className="mt-0 flex flex-row min-h-0 flex-1">
<div className="flex flex-col min-h-0 flex-1">
{/* Row 1: actions + per-server pills */}
<div className="flex items-center gap-2 px-2.5 pt-2.5 flex-wrap">
<Button
variant="outline" size="sm"
onClick={async () => { await ConnectAllClusters().catch((e) => setError(String(e?.message ?? e))); await reloadClusterMeta(); }}
>
Connect all
</Button>
<Button
variant="outline" size="sm"
onClick={async () => { await DisconnectAllClusters().catch(() => {}); await reloadClusterMeta(); }}
>
Disconnect all
</Button>
{clusterServerStatuses.length === 0 && (
<span className="text-xs text-muted-foreground italic">
No active sessions configure clusters in Settings DX Cluster.
</span>
)}
{clusterServerStatuses.map((s) => {
const isMaster = clusterServers
.filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
return (
<span
key={s.server_id}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border',
s.state === 'connected' ? 'bg-emerald-100 text-emerald-800 border-emerald-300' :
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' :
s.state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' :
'bg-muted text-muted-foreground border-border',
)}
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
>
{isMaster && <span className="text-amber-600" title="Master (commands go here)"></span>}
{s.name}
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span>
</span>
);
})}
<div className="flex-1" />
<Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
{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)} />
</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} />
</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 />
</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. */}
<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">Bands:</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>
);
})}
</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)}
/>
</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-emerald-500' : 'bg-muted-foreground/40')} />
{label}
</button>
);
return (
<footer className="flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
<span className="text-[11px] text-muted-foreground">
QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
</span>
<div className="w-px h-4 bg-border mx-1" />
<Chip on={clusterUp} label="Cluster" title={clusterUp ? 'Cluster connected' : 'Cluster offline'} onClick={() => setActiveTab('cluster')} />
<Chip
on={catUp}
label={<span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{catUp ? (catState.rig || 'CAT') : (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT')}</span>}
title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')}
onClick={() => { setSettingsSection('cat'); setShowSettings(true); }}
/>
<Chip
on={rotatorHeading.enabled && rotatorHeading.ok}
label={rotatorHeading.enabled && rotatorHeading.ok ? `Rotator ${rotatorHeading.azimuth}°` : 'Rotator'}
title={rotatorHeading.enabled ? (rotatorHeading.ok ? `Antenna heading ${rotatorHeading.azimuth}°` : 'Rotator: no position') : 'Rotator disabled'}
disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/>
<div className="flex-1" />
{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-emerald-600' : '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: the entry TX freq (kHz), else the last logged QSO's.
defaultFreqKHz={
parseFloat(freqMhz) > 0
? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
}
defaultMode={mode || qsos[0]?.mode || ''}
targetName={
clusterServers
.filter((s) => s.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.name
}
recent={qsos.slice(0, 8).map((q): RecentSpotQSO => ({
callsign: q.callsign,
freqKHz: q.freq_hz ? Math.round((q.freq_hz / 1000) * 10) / 10 : 0,
mode: q.mode ?? '',
band: q.band,
}))}
onSend={async (call, freqKHz, comment) => {
await SendClusterSpot(call, freqKHz, comment);
const target = clusterServers
.filter((s) => s.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.name;
showToast(`Spot ${call} sent${target ? ` on ${target}` : ''}`);
}}
/>
{showSettings && (
<SettingsModal
initialSection={settingsSection}
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
flexAvailable={catState.backend === 'flex'}
/>
)}
<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>
);
}