Files
OpsLog/frontend/src/App.tsx
T
2026-05-30 01:35:50 +02:00

2252 lines
105 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, ExternalLink, Hash, Loader2, Lock,
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X,
} from 'lucide-react';
import {
AddQSO, ListQSO, CountQSO,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF,
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus,
WorkedBefore,
SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
RefreshCtyDat,
RotatorGoTo, RotatorStop, GetRotatorHeading,
OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
GetCATSettings,
OperatingDefaultForBand,
LogUDPLoggedADIF,
ListCountries,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { EventsOn } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
import { Menubar, type Menu } from '@/components/Menubar';
import { QSLManagerPanel } from '@/components/QSLManagerModal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { pathBetween } from '@/lib/maidenhead';
import { flagURL } from '@/lib/flags';
type QSO = QSOForm;
type ImportResult = adifModels.ImportResult;
type LookupResult = lookupModels.Result;
type StationSettings = StationSettingsForm;
type ListsSettings = ListsSettingsForm;
type ModePreset = ModePresetForm;
type WB = WorkedBeforeView;
type CATState = Omit<catModels.RigState, 'convertValues'>;
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
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: '',
};
function fmtDateUTC(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return s;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
function fmtFreq(hz?: number): string {
if (!hz) return '';
return (hz / 1_000_000).toFixed(4);
}
// cleanSpotter / inferSpotMode / spotStatusKey live in lib/spot.ts so
// the BandMap component reads from the same canonical source — keeps
// "CW spot looks like CW everywhere" honest.
function fmtHMSUTC(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
}
// parseHMSUTC parses "HH:MM" or "HH:MM:SS" and returns a Date with that
// UTC time on the same UTC day as base. Tolerates "HHMM" / "HHMMSS" too
// (no separators) so the user can type fast. Falls back to base on bad input.
function parseHMSUTC(s: string, base: Date): Date {
const clean = s.replace(/[^0-9:]/g, '');
let h = 0, m = 0, sec = 0;
if (clean.includes(':')) {
const parts = clean.split(':');
h = parseInt(parts[0] ?? '0', 10) || 0;
m = parseInt(parts[1] ?? '0', 10) || 0;
sec = parseInt(parts[2] ?? '0', 10) || 0;
} else if (clean.length >= 4) {
h = parseInt(clean.slice(0, 2), 10) || 0;
m = parseInt(clean.slice(2, 4), 10) || 0;
sec = clean.length >= 6 ? (parseInt(clean.slice(4, 6), 10) || 0) : 0;
} else {
return base;
}
if (h > 23 || m > 59 || sec > 59) return base;
const d = new Date(base);
d.setUTCHours(h, m, sec, 0);
return d;
}
// fmtFreqDots formats a MHz string for display as MHz.kHz.Hz with dot
// separators (14.266500 → "14.266.500"). Pads the fractional part to 6
// digits so partial inputs ("14.21") still render as "14.210.000".
function fmtFreqDots(mhzStr: string): string {
if (!mhzStr) return '';
const [intPart, fracRaw = ''] = mhzStr.split('.');
const frac = (fracRaw + '000000').slice(0, 6);
return `${intPart}.${frac.slice(0, 3)}.${frac.slice(3, 6)}`;
}
// shortCatError condenses a backend error into a few words for the topbar
// pill. The full message stays in the tooltip. Recognises the common cases
// (OmniRig not installed, not registered) and otherwise truncates.
function shortCatError(err?: string): string {
if (!err) return '';
const e = err.toLowerCase();
if (e.includes('not registered') || e.includes('not available')) return 'OmniRig not found';
if (e.includes('not connected')) return 'not connected';
if (e.includes('coinitialize')) return 'COM error';
return err.length > 24 ? err.slice(0, 22) + '…' : err;
}
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' if outside
// every known allocation (used to auto-fill the band when the freq changes).
function bandForMHz(mhz: number): string {
if (!mhz || isNaN(mhz)) return '';
const plan: [number, number, string][] = [
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [1240, 1300, '23cm'],
];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return '';
}
// rstCategory buckets a mode into the report family used for its RST list.
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
function rstCategory(mode: string): keyof RSTLists {
const m = (mode || '').toUpperCase();
const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
if (digital.includes(m)) return 'digital';
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
return 'phone';
}
// rstOptions returns the valid report choices for a mode from the user's
// editable lists (Settings → Modes), with a tiny fallback before they load.
function rstOptions(mode: string, lists: RSTLists): string[] {
const cat = rstCategory(mode);
const l = lists[cat];
if (l && l.length) return l;
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
}
function computePrefix(call: string): string {
if (!call) return '';
const c = call.trim().toUpperCase().split('/')[0];
let lastDigit = -1;
for (let i = 0; i < c.length; i++) {
if (c[i] >= '0' && c[i] <= '9') lastDigit = i;
}
return lastDigit >= 0 ? c.slice(0, lastDigit + 1) : c;
}
export default function App() {
// === Lists from settings (fallback for first paint) ===
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
const [modePresets, setModePresets] = useState<ModePreset[]>([]);
// === Entry ===
const [callsign, setCallsign] = useState('');
// Ref to the callsign input so ESC can snap focus back to it.
const callsignRef = useRef<HTMLInputElement>(null);
// QSO start time — frozen when the operator starts typing the callsign,
// logged as ADIF QSO_DATE. End time = save click (logged as QSO_DATE_OFF).
const [qsoStartedAt, setQsoStartedAt] = useState<Date | null>(null);
// Frozen end time used when the End-UTC field is locked (backdated QSO);
// null means the field tracks the live clock.
const [qsoEndedAt, setQsoEndedAt] = useState<Date | null>(null);
// Local string buffers for the time inputs while the user is editing.
// Parsing only on blur — otherwise every keystroke would re-format the
// value and React would move the cursor back to the end of the field.
const [startInputStr, setStartInputStr] = useState('');
const [endInputStr, setEndInputStr] = useState('');
const [startFocused, setStartFocused] = useState(false);
const [endFocused, setEndFocused] = useState(false);
const [freqFocused, setFreqFocused] = useState(false);
// Per-field locks — like Log4OM's padlocks. When locked, CAT updates skip
// that field and the time fields become editable so the user can log a
// QSO from the past while the radio is parked elsewhere.
type LockKey = 'band' | 'mode' | 'freq' | 'start' | 'end';
const [locks, setLocks] = useState<Record<LockKey, boolean>>({
band: false, mode: false, freq: false, start: false, end: false,
});
const locksRef = useRef(locks);
useEffect(() => { locksRef.current = locks; }, [locks]);
const toggleLock = (k: LockKey) => {
setLocks((s) => {
const wasLocked = s[k];
const next = { ...s, [k]: !wasLocked };
// Unlocking → restore automatic behavior. Without this the locked
// value would linger forever: a stale Start time would never refresh
// even after a new callsign is entered.
if (wasLocked) {
if (k === 'start') {
// If a QSO is currently in progress (callsign typed), snap start
// to now since we missed the auto-start moment. Otherwise clear.
setQsoStartedAt(callsign.trim() ? new Date() : null);
} else if (k === 'end') {
// Drop the frozen end so the field tracks the live UTC clock.
setQsoEndedAt(null);
}
}
return next;
});
};
// Small padlock toggle rendered inside each lockable field's label. Match
// the icon to the current state so the user can tell at a glance which
// fields are immune to CAT updates / live clock.
function LockBtn({ k, title }: { k: LockKey; title: string }) {
const on = locks[k];
const Icon = on ? Lock : Unlock;
return (
<button
type="button"
tabIndex={-1}
onClick={() => toggleLock(k)}
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
className={cn(
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
on ? 'text-amber-600 hover:text-amber-700' : 'text-muted-foreground/40 hover:text-muted-foreground',
)}
>
<Icon className="size-3" />
</button>
);
}
const [band, setBand] = useState('20m');
const [mode, setMode] = useState('SSB');
const [freqMhz, setFreqMhz] = useState('');
// RX freq for split — only set/shown when the rig is in split mode.
const [rxFreqMhz, setRxFreqMhz] = useState('');
// RX band — follows the TX band by default; only differs for cross-band work.
const [bandRx, setBandRx] = useState('20m');
const [countries, setCountries] = useState<string[]>([]);
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
const [rstSent, setRstSent] = useState('59');
const [rstRcvd, setRstRcvd] = useState('59');
const [grid, setGrid] = useState('');
const [name, setName] = useState('');
const [qth, setQth] = useState('');
const [country, setCountry] = useState('');
const [comment, setComment] = useState('');
const [note, setNote] = useState('');
// Compact (popout) mode: shrinks the window + always-on-top so the entry
// strip can sit on top of WSJT-X / JT-Alert / the cluster.
const [compact, setCompact] = useState(false);
function toggleCompact() {
const next = !compact;
setCompact(next);
SetCompactMode(next);
}
// CAT — receives live rig state via Wails events.
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
// in Preferences > Hardware > CAT interface.
const digitalDefaultRef = useRef<string>('FT8');
// Don't override freq/band/mode the user JUST typed — track a small grace
// window after manual edits and skip CAT updates during it.
const catFreezeUntilRef = useRef<number>(0);
function noteManualEdit() { catFreezeUntilRef.current = Date.now() + 1500; }
// Suggested QSY frequency (Hz) for a given band + mode. Common phone /
// CW / FT8 watering holes per IARU practice. Fallback = mid-band SSB freq.
// TODO: make this a user preference per band/mode later.
const QSY_TABLE: Record<string, Record<string, number>> = {
'160m': { SSB: 1842000, CW: 1820000, FT8: 1840000, FT4: 1840000, RTTY: 1838000, default: 1840000 },
'80m': { SSB: 3750000, CW: 3520000, FT8: 3573000, FT4: 3575000, RTTY: 3580000, default: 3750000 },
'60m': { default: 5357000 },
'40m': { SSB: 7150000, CW: 7030000, FT8: 7074000, FT4: 7047500, RTTY: 7040000, default: 7150000 },
'30m': { CW: 10116000, FT8: 10136000, FT4: 10140000, RTTY: 10142000, default: 10136000 },
'20m': { SSB: 14250000, CW: 14030000, FT8: 14074000, FT4: 14080000, RTTY: 14080000, default: 14250000 },
'17m': { SSB: 18140000, CW: 18080000, FT8: 18100000, FT4: 18104000, RTTY: 18105000, default: 18140000 },
'15m': { SSB: 21300000, CW: 21030000, FT8: 21074000, FT4: 21140000, RTTY: 21080000, default: 21300000 },
'12m': { SSB: 24950000, CW: 24910000, FT8: 24915000, FT4: 24919000, RTTY: 24920000, default: 24950000 },
'10m': { SSB: 28500000, CW: 28030000, FT8: 28074000, FT4: 28180000, RTTY: 28080000, default: 28500000 },
'6m': { SSB: 50150000, CW: 50080000, FT8: 50313000, FT4: 50318000, default: 50150000 },
'2m': { SSB: 144300000, CW: 144050000, FT8: 144174000, default: 144300000 },
'70cm': { SSB: 432300000, CW: 432050000, FT8: 432174000, default: 432300000 },
};
function qsyFreqHz(band: string, mode: string): number {
const t = QSY_TABLE[band];
if (!t) return 0;
return t[mode] ?? t.default ?? 0;
}
// Digital watering holes (±3 kHz tolerance). When the rig is parked here
// CAT typically reports SSB (USB-D under the hood) which is misleading —
// auto-promote to the specific digital mode for the entry strip.
const DIGITAL_FREQS: { freq: number; mode: string }[] = [
{ freq: 1.840, mode: 'FT8' },
{ freq: 3.573, mode: 'FT8' },
{ freq: 5.357, mode: 'FT8' },
{ freq: 7.074, mode: 'FT8' }, { freq: 7.0475, mode: 'FT4' },
{ freq: 10.136, mode: 'FT8' }, { freq: 10.140, mode: 'FT4' },
{ freq: 14.074, mode: 'FT8' }, { freq: 14.080, mode: 'FT4' },
{ freq: 18.100, mode: 'FT8' }, { freq: 18.104, mode: 'FT4' },
{ freq: 21.074, mode: 'FT8' }, { freq: 21.140, mode: 'FT4' },
{ freq: 24.915, mode: 'FT8' }, { freq: 24.919, mode: 'FT4' },
{ freq: 28.074, mode: 'FT8' }, { freq: 28.180, mode: 'FT4' },
{ freq: 50.313, mode: 'FT8' }, { freq: 50.318, mode: 'FT4' },
{ freq: 144.174, mode: 'FT8' },
];
function inferDigitalMode(freqHz: number): string {
const mhz = freqHz / 1_000_000;
for (const d of DIGITAL_FREQS) {
if (Math.abs(mhz - d.freq) <= 0.003) return d.mode;
}
return '';
}
// User changed band/mode in the entry strip → push to the rig if CAT is up.
// Both calls are fire-and-forget; CAT will reflect back via cat:state.
function onBandUserChange(v: string) {
setBand(v);
noteManualEdit();
if (catState.enabled && catState.connected) {
const hz = qsyFreqHz(v, mode);
if (hz > 0) SetCATFrequency(hz).catch(() => {});
}
}
function onModeUserChange(v: string) {
setMode(v);
applyModePreset(v);
noteManualEdit();
if (catState.enabled && catState.connected) {
SetCATMode(v).catch(() => {});
}
}
const userEditedRef = useRef<Set<string>>(new Set());
const lastLookedUpRef = useRef<string>('');
const rstUserEditedRef = useRef<boolean>(false);
const [details, setDetails] = useState<DetailsState>(emptyDetails);
const updateDetails = useCallback((patch: Partial<DetailsState>) => {
setDetails((d) => ({ ...d, ...patch }));
}, []);
// Auto-fill MY_RIG / MY_ANTENNA from the operating conditions tree
// whenever the band changes. The backend resolves the "default antenna
// for this band" within the active profile and returns the (rig,
// antenna) tuple. Empty result → we DO clear the fields so leftover
// values from a previous band don't get logged against the wrong gear.
useEffect(() => {
if (!band) return;
let cancelled = false;
OperatingDefaultForBand(band).then((d) => {
if (cancelled) return;
setDetails((cur) => ({
...cur,
my_rig: d?.station_name || '',
my_antenna: d?.antenna_name || '',
tx_pwr: d?.tx_pwr ?? cur.tx_pwr,
}));
}).catch(() => {});
return () => { cancelled = true; };
}, [band]);
const prefix = useMemo(() => computePrefix(callsign), [callsign]);
// Bearing/distance from operator's home grid to the remote station —
// shown live in the entry strip (SP azimuth) and Info tab (LP + dist).
// === Logbook list ===
const [qsos, setQsos] = useState<QSO[]>([]);
const [total, setTotal] = useState<number>(0);
const [error, setError] = useState('');
// Transient success toast (bottom-right, auto-dismiss). Used for things
// like "spot sent" where a blocking error banner would be overkill.
const [toast, setToast] = useState('');
const showToast = useCallback((msg: string) => {
setToast(msg);
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500);
}, []);
const [saving, setSaving] = useState(false);
const [filterCallsign, setFilterCallsign] = useState('');
const [filterBand, setFilterBand] = useState('');
const [filterMode, setFilterMode] = useState('');
const [activeTab, setActiveTab] = useState('recent');
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
const [qslTabOpen, setQslTabOpen] = useState(false);
function closeQslTab() {
setQslTabOpen(false);
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
}
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
// huge logs render OK once loaded, but a 25k+ logbook still takes a
// couple of seconds to round-trip from SQLite at launch. Defaulting
// to 500 keeps the first paint instant; the user can bump to "All"
// when they actually want to search history.
const [qsoLimit, setQsoLimit] = useState<number>(() => {
const raw = Number(localStorage.getItem('hamlog.qsoLimit') ?? '500');
return Number.isFinite(raw) && raw > 0 ? raw : 500;
});
useEffect(() => { localStorage.setItem('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
// === DX Cluster live state ===
type ClusterSpot = {
source_id: number;
source_name: string;
spotter: string;
dx_call: string;
freq_khz: number;
freq_hz: number;
band?: string;
comment?: string;
locator?: string;
time_utc?: string;
country?: string;
continent?: string;
cqz?: number;
ituz?: number;
distance_km?: number;
sp_deg?: number;
lp_deg?: number;
received_at: string;
raw: string;
};
type ServerStatus = {
server_id: number;
name: string;
host: string;
port: number;
state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
login?: string;
error?: string;
spots_count?: number;
retries?: number;
};
const [clusterServerStatuses, setClusterServerStatuses] = useState<ServerStatus[]>([]);
// "Send DX spot" dialog (Log4OM-style) — only reachable when a cluster is up.
const [showSpotModal, setShowSpotModal] = useState(false);
const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]);
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
const [spots, setSpots] = useState<ClusterSpot[]>([]);
const SPOTS_CAP = 1000;
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
const [clusterGroup, setClusterGroup] = useState(true);
const [clusterCmd, setClusterCmd] = useState('');
// Multi-band filter: empty set = all bands. The user toggles chips.
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
// Lock-to-entry: when on, the band filter follows the entry's current
// band and the mode filter follows the entry's current mode.
const [clusterLockBand, setClusterLockBand] = useState(false);
const [clusterLockMode, setClusterLockMode] = useState(false);
// Status filter chips. Empty set = show every status (including
// already-worked). Otherwise only matching spots pass.
type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
// Mode filter chips. Empty set = show every mode. Categories map the
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
const [clusterSearch, setClusterSearch] = useState('');
const [showBandMap, setShowBandMap] = useState(false);
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
// different slots don't share the same colour.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean }>>({});
// === Modals ===
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [showSettings, setShowSettings] = useState(false);
// Optional deep-link: which Preferences section to open. Cleared on
// close so the next plain "Preferences" launch reverts to default.
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
const [showDeleteAll, setShowDeleteAll] = useState(false);
const [deletingAll, setDeletingAll] = useState(false);
const [ctyRefreshing, setCtyRefreshing] = useState(false);
// === ADIF ===
const [importing, setImporting] = useState(false);
const [exporting, setExporting] = 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 [importSkipDups, setImportSkipDups] = useState(true);
// === 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);
// === Station ===
const [station, setStation] = useState<StationSettings>({
callsign: '', operator: '',
my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '',
});
// === 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);
}, []);
const refresh = useCallback(async () => {
try {
const list = await ListQSO({
callsign: filterCallsign, band: filterBand, mode: filterMode,
limit: qsoLimit, offset: 0,
} as any);
const n = await CountQSO();
setQsos(list);
setTotal(n);
setError('');
} catch (e: any) {
setError(String(e?.message ?? e));
}
}, [filterCallsign, filterBand, filterMode, qsoLimit]);
// Refresh the Recent QSOs grid after external-service uploads stamp the
// sent status (auto-upload via extsvc:uploaded, or manual QSL Manager via
// qslmgr:done). Debounced so a batch of per-QSO events triggers one reload.
useEffect(() => {
let t: number | undefined;
const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
const offUploaded = EventsOn('extsvc:uploaded', ping);
const offDone = EventsOn('qslmgr:done', ping);
return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); };
}, [refresh]);
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
// rotator is disabled (the backend just reads settings and returns).
useEffect(() => {
let alive = true;
const tick = async () => {
try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {}
};
tick();
const id = window.setInterval(tick, 3000);
return () => { alive = false; window.clearInterval(id); };
}, []);
// RX band auto-follows the TX band (only differs for cross-band work).
useEffect(() => { setBandRx(band); }, [band]);
// RX freq mirrors TX freq on every TX change (unless the rig is in split,
// where the RX freq is genuinely different). It stays editable by hand:
// a manual RX edit sticks until the next TX-freq change re-syncs it.
useEffect(() => {
if (!catState.split) setRxFreqMhz(freqMhz);
}, [freqMhz, catState.split]);
// Load the DXCC country list for the Country picker. cty.dat loads a few
// seconds after startup, so retry until it's available.
useEffect(() => {
let tries = 0;
let timer = 0;
const load = async () => {
try {
const c = await ListCountries();
if (c && c.length) { setCountries(c); return; }
} catch {}
if (tries++ < 15) timer = window.setTimeout(load, 2000);
};
load();
return () => { if (timer) window.clearTimeout(timer); };
}, []);
const loadStation = useCallback(async () => {
try { setStation(await GetStationSettings()); } catch {}
}, []);
const loadCATCfg = useCallback(async () => {
try {
const c = await GetCATSettings();
if (c.digital_default) digitalDefaultRef.current = c.digital_default;
} catch {}
}, []);
const loadLists = useCallback(async () => {
try {
const l: ListsSettings = await GetListsSettings();
setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] });
if (l.bands && l.bands.length) setBands(l.bands);
if (l.modes && l.modes.length) {
setModePresets(l.modes);
const names = l.modes.map((m) => m.name);
setModes(names);
setMode((cur) => names.includes(cur) ? cur : names[0]);
const preset = l.modes.find((m) => m.name === mode) ?? l.modes[0];
if (preset && !rstUserEditedRef.current) {
if (preset.default_rst_sent) setRstSent(preset.default_rst_sent);
if (preset.default_rst_rcvd) setRstRcvd(preset.default_rst_rcvd);
}
}
} catch {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function applyModePreset(m: string) {
if (rstUserEditedRef.current) return;
const p = modePresets.find((x) => x.name === m);
if (!p) return;
if (p.default_rst_sent) setRstSent(p.default_rst_sent);
if (p.default_rst_rcvd) setRstRcvd(p.default_rst_rcvd);
}
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
(async () => {
try {
const st = await GetStartupStatus();
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
} catch {}
loadStation();
loadLists();
loadCATCfg();
// Hydrate CAT state on mount (the backend may already be polling).
try { setCatState(await GetCATState()); } catch {}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// CAT live updates. Push freq/band/mode into the entry strip when the rig
// moves, unless the user just typed something (1.5s grace window).
useEffect(() => {
const unsub = EventsOn('cat:state', (s: CATState) => {
setCatState(s);
if (!s?.connected) return;
if (Date.now() < catFreezeUntilRef.current) return;
const lk = locksRef.current;
if (!lk.freq && s.freq_hz && s.freq_hz > 0) {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
}
// RX freq: backend follows ADIF — freq_hz = TX, freq_rx_hz = RX.
// In split we take the rig's real RX freq; otherwise RX mirrors TX
// (the user can still override it by hand). The freq lock covers both.
if (!lk.freq) {
if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) {
setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5));
} else if (s.freq_hz && s.freq_hz > 0) {
setRxFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
}
}
if (!lk.band && s.band) setBand(s.band);
// Mode resolution priority:
// 1. If freq matches a known digital watering hole, pick the specific
// mode for that hole (FT8 / FT4) — beats whatever CAT reports.
// 2. Else if CAT reports DATA (generic), use the user's configured
// default digital mode (FT8 by default).
// 3. Else trust CAT (SSB, CW, AM, FM…).
if (!lk.mode) {
const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
if (inferred) {
setMode(inferred);
} else if (s.mode === 'DATA') {
setMode(digitalDefaultRef.current || 'FT8');
} else if (s.mode) {
setMode(s.mode);
}
}
});
return () => { unsub?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Cluster live wiring: hydrate per-server status + saved server list,
// then subscribe to push events.
async function reloadClusterMeta() {
try {
const [st, list] = await Promise.all([GetClusterStatus(), ListClusterServers()]);
setClusterServerStatuses((st ?? []) as ServerStatus[]);
setClusterServers(((list ?? []) as any[]).map((s) => ({
id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0,
})));
} catch {}
}
useEffect(() => {
reloadClusterMeta();
// cluster:state fires on connect/disconnect/save/delete — refresh
// the saved-server list too so the source dropdown stays in sync
// when the user adds, deletes or toggles a row in Settings.
const unsubState = EventsOn('cluster:state', async (sts: ServerStatus[]) => {
setClusterServerStatuses(sts ?? []);
try {
const list = await ListClusterServers();
setClusterServers(((list ?? []) as any[]).map((s) => ({
id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0,
})));
} catch {}
// Drop any buffered spots whose source server is no longer in the
// status list (it was disconnected / deleted). Without this the
// table keeps showing stale rows from a server the user just
// turned off.
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
});
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
setSpots((arr) => {
const next = [sp, ...arr];
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
});
});
return () => { unsubState?.(); unsubSpot?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── UDP integration events ───────────────────────────────────────────
// Live updates from external apps (WSJT-X / JTDX / MSHV / DXHunter…).
// We push the broadcast DX call into the entry field and auto-log any
// ADIF record that arrives.
useEffect(() => {
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
const call = String(p?.call ?? '').trim();
if (!call) return;
// Don't clobber what the user is currently typing — only update
// when the entry field is empty or matches a previous broadcast.
onCallsignInput(call);
});
const unsubRC = EventsOn('udp:remote_call', (call: string) => {
if (call) onCallsignInput(String(call).trim());
});
const 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) {
setError('UDP auto-log: ' + String(e?.message ?? e));
}
});
return () => { unsubDX?.(); unsubRC?.(); unsubLog?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Resolve slot status for any spot we haven't seen yet — debounced so we
// don't hammer the backend at firehose rate. The mode passed to the
// backend is derived from the comment (CW / FT8 / FT4 / SSB...) and the
// band-plan fallback, NOT just digital watering-hole detection — that's
// how CW spots get correctly classified instead of being labelled
// "new-slot" because the lookup key carried mode="".
useEffect(() => {
const t = window.setTimeout(async () => {
const unknown: { call: string; band: string; mode: string }[] = [];
const seen = new Set<string>();
for (const s of spots) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
if (seen.has(k) || spotStatus[k]) continue;
seen.add(k);
unknown.push({ call: s.dx_call, band: s.band ?? '', mode });
}
if (unknown.length === 0) return;
try {
const res = await ClusterSpotStatuses(unknown as any);
setSpotStatus((prev) => {
const next = { ...prev };
for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = {
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
};
}
return next;
});
} catch {}
}, 400);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spots]);
async function save() {
if (!callsign.trim()) { setError('Callsign required'); return; }
setSaving(true); setError('');
try {
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
const rxFreqHz = rxFreqMhz.trim() ? Math.round(parseFloat(rxFreqMhz) * 1_000_000) : undefined;
const now = new Date();
const start = qsoStartedAt ?? now;
const end = (locks.end && qsoEndedAt) ? qsoEndedAt : now;
const payload: any = {
callsign: callsign.trim().toUpperCase(),
qso_date: start.toISOString(),
qso_date_off: end.toISOString(),
band, band_rx: bandRx, mode, freq_hz: freqHz, freq_rx_hz: rxFreqHz,
rst_sent: rstSent, rst_rcvd: rstRcvd,
grid: grid.trim().toUpperCase(),
name, qth, country, comment, notes: note,
state: details.state, cnty: details.cnty, address: details.address,
lat: details.lat, lon: details.lon,
dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz, cont: details.cont || undefined,
qsl_msg: details.qsl_msg, qsl_via: details.qsl_via,
ant_az: details.ant_az, ant_el: details.ant_el, ant_path: details.ant_path,
prop_mode: details.prop_mode,
my_rig: details.my_rig, my_antenna: details.my_antenna,
tx_pwr: details.tx_pwr,
sat_name: details.sat_name, sat_mode: details.sat_mode,
contest_id: details.contest_id,
srx: details.srx, stx: details.stx,
email: details.email,
};
await AddQSO(payload);
resetEntry();
await refresh();
} catch (e: any) {
setError(String(e?.message ?? e));
} finally { setSaving(false); }
}
// resetEntry clears the form for the next QSO. Triggered after a
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
// are preserved so backdated batches stay productive.
function resetEntry() {
setCallsign(''); setComment(''); setNote('');
if (!locks.start) setQsoStartedAt(null);
if (!locks.end) setQsoEndedAt(null);
resetAutoFill();
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: '',
}));
}
function resetAutoFill() {
setName(''); setQth(''); setCountry(''); setGrid('');
setWb(null);
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 = '';
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(); }
catch (err: any) { setError(String(err?.message ?? err)); }
}
function onModalDelete(id: number) {
const q = editingQSO; setEditingQSO(null);
if (q) setDeletingQSO(q); else askDelete(id);
}
function askDelete(id: number) {
const q = qsos.find((x) => x.id === id);
if (q) setDeletingQSO(q);
}
async function confirmDelete() {
if (!deletingQSO) return;
try {
await DeleteQSO(deletingQSO.id);
if (selectedId === deletingQSO.id) setSelectedId(null);
setDeletingQSO(null);
await refresh();
} catch (err: any) {
setError(String(err?.message ?? err));
setDeletingQSO(null);
}
}
async function confirmDeleteAll() {
if (deletingAll) return;
setDeletingAll(true);
try {
await DeleteAllQSO();
setSelectedId(null);
setShowDeleteAll(false);
await refresh();
} catch (err: any) { setError(String(err?.message ?? err)); }
finally { setDeletingAll(false); }
}
async function runWorkedBefore(call: string, dxccHint: number = 0) {
setWbBusy(true);
try { setWb(await WorkedBefore(call, dxccHint)); }
catch { setWb(null); }
finally { setWbBusy(false); }
}
async function runLookup(call: string) {
if (call !== lastLookedUpRef.current) resetAutoFill();
setLookupBusy(true);
try {
const r = await LookupCallsign(call);
setLookupResult(r);
lastLookedUpRef.current = call;
const ue = userEditedRef.current;
if (!ue.has('name')) setName(r.name ?? '');
if (!ue.has('qth')) setQth(r.qth ?? '');
if (!ue.has('country')) setCountry(r.country ?? '');
if (!ue.has('grid')) setGrid(r.grid ?? '');
setDetails((d) => ({
...d,
address: d.address || (r.address ?? ''),
state: d.state || (r.state ?? ''),
cnty: d.cnty || (r.cnty ?? ''),
lat: d.lat ?? (r.lat || undefined),
lon: d.lon ?? (r.lon || undefined),
dxcc: d.dxcc ?? (r.dxcc || undefined),
cqz: d.cqz ?? (r.cqz || undefined),
ituz: d.ituz ?? (r.ituz || undefined),
cont: d.cont || (r.cont ?? ''),
email: d.email || (r.email ?? ''),
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
}));
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
} catch (e: any) {
setLookupResult(null);
setLookupError(String(e?.message ?? e));
} finally { setLookupBusy(false); }
}
function scheduleLookup(value: string) {
setLookupError('');
if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current);
if (wbTimerRef.current) window.clearTimeout(wbTimerRef.current);
const call = value.trim().toUpperCase();
if (call.length < 3) {
setLookupResult(null); setWb(null);
if (lastLookedUpRef.current !== '') resetAutoFill();
return;
}
lookupTimerRef.current = window.setTimeout(() => runLookup(call), 400);
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
}
function onCallsignInput(v: string) {
const wasEmpty = callsign.trim() === '';
const isEmpty = v.trim() === '';
if (wasEmpty && !isEmpty && !locks.start) {
// First keystroke of a new QSO — freeze the start time so it doesn't
// drift even if the lookup or typing takes 30 seconds. Skip when
// start is locked: the user is back-entering a past QSO and set a
// specific time manually.
setQsoStartedAt(new Date());
} else if (isEmpty && !locks.start) {
// Callsign wiped → user abandoned this QSO; reset the timer.
setQsoStartedAt(null);
}
setCallsign(v);
scheduleLookup(v);
}
function markEdited(field: string) { userEditedRef.current.add(field); }
async function importAdif() {
if (importing) return;
setError('');
try {
const path = await OpenADIFFile();
if (!path) return;
// Stash the path and open the options dialog. The actual import
// is fired from runImport() when the user clicks "Import".
setPendingImportPath(path);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
async function exportAdif() {
if (exporting) return;
setError('');
try {
const path = await SaveADIFFile();
if (!path) return;
setExporting(true);
const res = await ExportADIF(path);
// Reuse the error banner area for a brief success note (4s auto-dismiss).
const msg = `ADIF exported: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`;
setError(msg);
setTimeout(() => setError((e) => e === msg ? '' : e), 4000);
} catch (e: any) {
setError(`ADIF export failed: ${String(e?.message ?? e)}`);
} finally {
setExporting(false);
}
}
async function runImport() {
const path = pendingImportPath;
if (!path || importing) return;
setPendingImportPath(null);
setImporting(true);
setImportResult(null);
setImportErrorsOpen(false);
setImportDupsOpen(false);
try {
const res = await ImportADIF(path, importSkipDups);
setImportResult(res);
await refresh();
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setImporting(false);
}
}
const menus: Menu[] = useMemo(() => [
{ name: 'file', label: 'File', items: [
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
{ type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
{ type: 'separator' },
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
{ type: 'separator' },
{ type: 'item', label: 'Exit', action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true },
]},
{ name: 'edit', label: 'Edit', items: [
{ type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
{ type: 'item', label: 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
{ type: 'separator' },
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' },
]},
{ name: 'view', label: 'View', items: [
{ type: 'item', label: 'Refresh', action: 'view.refresh', shortcut: 'F5' },
{ type: 'item', label: 'Clear filters', action: 'view.clearfilters' },
]},
{ name: 'tools', label: 'Tools', items: [
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
{ type: 'separator' },
{ type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' },
{ type: 'item', label: 'CAT interface…', action: 'tools.cat' },
{ type: 'item', label: 'Rotator…', action: 'tools.rotator' },
{ type: 'separator' },
// Maintenance — bumped here while we only have one entry. Will move
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
]},
{ name: 'help', label: 'Help', items: [
{ type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true },
]},
], [total, selectedId, ctyRefreshing, exporting]);
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(''); setFilterBand(''); setFilterMode(''); break;
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
case 'edit.prefs': setShowSettings(true); break;
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break;
case 'tools.cat': setSettingsSection('cat'); setShowSettings(true); break;
case 'tools.rotator': setSettingsSection('rotator'); setShowSettings(true); break;
case 'tools.refreshCty': refreshCtyDat(); break;
}
}
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';
if (e.key === 'F5') { e.preventDefault(); refresh(); return; }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') {
e.preventDefault(); importAdif(); return;
}
if (typing) return;
if (selectedId !== null) {
if (e.key === 'Delete') { e.preventDefault(); askDelete(selectedId); return; }
if (e.key === 'Enter') { e.preventDefault(); openEdit(selectedId); return; }
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId, refresh]);
return (
<div className="flex flex-col h-screen bg-background">
<ShutdownProgress />
{/* ===== TOPBAR ===== */}
{compact ? (
// Minimal compact topbar — brand + freq + toggle. Saves vertical space
// so the single-row entry strip fits in a ~140px tall window.
<header className="flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
<div className="flex items-center gap-1.5">
<div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
<span className="font-bold text-xs tracking-tight">OpsLog</span>
</div>
<div className="flex items-baseline gap-1.5 font-mono ml-2">
<span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
<span className="text-[9px] text-muted-foreground">MHz</span>
<Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge>
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono text-[9px] py-0" variant="outline">{mode}</Badge>
</div>
<span className="ml-2 font-mono text-[10px] text-muted-foreground">{utcNow}<span className="text-[9px]">Z</span></span>
<div className="flex-1" />
{station.callsign && (
<span className="font-mono text-[11px] font-bold text-primary mr-2">{station.callsign}</span>
)}
<Button variant="outline" size="icon" className="size-6" onClick={toggleCompact} title="Exit compact mode">
<Maximize2 className="size-3" />
</Button>
</header>
) : (
<header className="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
<span className="font-bold text-[15px] tracking-tight">OpsLog</span>
<span className="text-[11px] text-muted-foreground">v0.1</span>
</div>
<Menubar menus={menus} onAction={handleMenu} />
<div className="flex items-center justify-center gap-2 font-mono">
<div className="flex flex-col items-end leading-none">
<span className="text-[22px] font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
{catState.split && rxFreqMhz && (
<span className="text-[10px] text-muted-foreground mt-0.5">
<span className="text-rose-600 font-semibold mr-1">RX</span>
{fmtFreqDots(rxFreqMhz)}
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground uppercase">MHz</span>
{catState.split && (
<Badge className="bg-amber-100 text-amber-800 border-amber-300 font-mono text-[10px] py-0" variant="outline">SPLIT</Badge>
)}
{/* Band & mode removed here — shown in the QSO entry strip below. */}
{catState.enabled && catState.backend === 'omnirig' && (
<div className="inline-flex rounded-md border border-border overflow-hidden text-[11px] font-sans font-semibold">
{[1, 2].map((n) => {
const active = (catState.rig_num || 1) === n;
return (
<button key={n} type="button" onClick={() => SwitchCATRig(n).catch(() => {})}
className={cn('px-2 py-0.5 transition-colors', active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}
title={`Use OmniRig Rig ${n}`}>R{n}</button>
);
})}
</div>
)}
<div className="w-px h-4 bg-border mx-2" />
{/* Bearing controls — three separate buttons so SP and LP are
both directly clickable, plus an always-visible Stop. The
old Shift/Ctrl shortcuts were not discoverable enough. */}
{(() => {
const p = pathBetween(station.my_grid, grid);
const disabled = !p;
const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)));
return (
<div className="inline-flex items-center rounded-full border border-sky-300 bg-sky-100 overflow-hidden text-[11px] font-mono font-semibold">
<button
type="button"
disabled={disabled}
onClick={() => p && goto(p.bearingShort)}
title={p
? `Rotate short-path · ${Math.round(p.distanceShort).toLocaleString()} km`
: (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
disabled
? 'text-muted-foreground/60 cursor-not-allowed'
: 'text-sky-800 hover:bg-sky-200 cursor-pointer',
)}
>
<Compass className="size-3" />
{p ? `${Math.round(p.bearingShort)}°` : '—°'}
</button>
<button
type="button"
disabled={disabled}
onClick={() => p && goto(p.bearingLong)}
title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
className={cn(
'px-1.5 py-0.5 border-l border-sky-300 text-[10px] transition-colors',
disabled
? 'text-muted-foreground/60 cursor-not-allowed'
: 'text-sky-800 hover:bg-sky-200 cursor-pointer',
)}
>
LP {p ? `${Math.round(p.bearingLong)}°` : '—'}
</button>
<button
type="button"
onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))}
title="Stop rotation"
className="px-1.5 py-0.5 border-l border-sky-300 text-rose-700 hover:bg-rose-100 hover:text-rose-800 cursor-pointer transition-colors"
>
<Square className="size-2.5 fill-current" />
</button>
</div>
);
})()}
</div>
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
<Clock className="size-3" />
{utcNow}<span className="text-[10px]">UTC</span>
</div>
<div className="flex items-center gap-3">
{station.callsign ? (
<button
onClick={() => { setSettingsSection('profiles'); setShowSettings(true); }}
className="flex items-center gap-1.5 bg-accent border border-accent-foreground/20 text-accent-foreground px-3 py-1 rounded-full font-mono text-xs font-bold hover:bg-accent/80 transition-colors"
title="Click to switch / edit profiles"
>
<Antenna className="size-3" />
{station.callsign}
{station.my_grid && (
<span className="bg-white/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
)}
</button>
) : (
<Button variant="outline" size="sm" onClick={() => setShowSettings(true)}>
<Settings className="size-3.5" /> Set station
</Button>
)}
<Button
variant={showBandMap ? 'default' : 'outline'}
size="sm"
onClick={() => setShowBandMap((v) => !v)}
title="Toggle band map (visible across all tabs)"
className="h-8"
>
Band map
</Button>
<Button
variant="outline"
size="icon"
onClick={toggleCompact}
title="Compact mode (small always-on-top window)"
className="size-8"
>
<Minimize2 className="size-3.5" />
</Button>
</div>
</header>
)}
{error && (
<div className="bg-destructive/10 text-destructive border-b border-destructive/30 px-4 py-2 flex items-start gap-3 text-xs">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<pre className="flex-1 font-mono whitespace-pre-wrap m-0">{error}</pre>
<button className="hover:text-destructive/70" onClick={() => setError('')}><X className="size-4" /></button>
</div>
)}
{/* Transient success toast (bottom-right). */}
{toast && (
<div className="fixed bottom-4 right-4 z-[100] flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
<Satellite className="size-4 shrink-0" />
<span>{toast}</span>
<button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
</div>
)}
{/* ===== ENTRY STRIP =====
Enter from any <input> inside the strip logs the QSO. Radix Selects
render as <button> elements and are ignored by this handler — they
keep their own keyboard behaviour. */}
<div className={cn(!compact && 'flex gap-2.5 items-stretch px-2.5 pt-2.5 shrink-0')}>
<section
className={cn('flex gap-2 items-end flex-wrap content-start px-3 py-2 bg-card shadow-sm border-border',
compact ? 'border-b shrink-0' : 'flex-1 min-w-[560px] max-w-[920px] border rounded-lg')}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
e.preventDefault();
save();
} else if (e.key === 'Escape') {
e.preventDefault();
resetEntry();
// Snap focus back to the callsign field, ready for the next QSO.
callsignRef.current?.focus();
}
}}
>
{/* ── Row 1: Callsign + RST ── */}
<div className="flex flex-col w-40">
<Label className="mb-1 flex items-center gap-2 h-3.5">
Callsign
{callsign.trim() && (
<button
type="button"
tabIndex={-1}
onClick={() => {
const c = callsign.trim().toUpperCase();
OpenExternalURL(`https://www.qrz.com/db/${encodeURIComponent(c)}`)
.catch((err) => setError(String(err?.message ?? err)));
}}
title="Open this callsign on QRZ.com"
className="inline-flex items-center justify-center size-3.5 rounded text-muted-foreground/60 hover:text-primary transition-colors"
>
<ExternalLink className="size-3" />
</button>
)}
{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>
)}
{/* Contacted entity flag (from its DXCC number). */}
{flagURL(details.dxcc) && (
<img src={flagURL(details.dxcc)} alt="" title={country}
className="h-3.5 ml-auto rounded-[2px] border border-border/50 shadow-sm"
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
)}
</Label>
<Input
ref={callsignRef}
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
value={callsign}
onChange={(e) => onCallsignInput(e.target.value)}
/>
</div>
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST tx</Label>
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
</div>
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST rx</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
</div>
<div className="flex flex-col w-24">
<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>
<div className="flex flex-col w-24">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-rose-700">
End UTC <LockBtn k="end" title="end time" />
</Label>
<Input
readOnly={!locks.end}
tabIndex={locks.end ? 0 : -1}
value={endFocused
? endInputStr
: (locks.end
? (qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : '')
: (qsoStartedAt ? utcNow.slice(11) : '—'))}
onFocus={() => {
setEndInputStr(qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : '');
setEndFocused(true);
}}
onBlur={() => {
setEndFocused(false);
if (endInputStr.trim() === '') {
if (locks.end) setQsoEndedAt(null);
return;
}
setQsoEndedAt(parseHMSUTC(endInputStr, qsoEndedAt ?? new Date()));
}}
onChange={(e) => setEndInputStr(e.target.value)}
placeholder="HH:MM:SS"
className={cn('font-mono', locks.end ? '' : 'bg-muted/40 cursor-default')}
/>
</div>
{/* ── Row 2: Operator name + QTH + Grid + Country + zones (hidden in compact) ── */}
{!compact && <>
<div className="basis-full h-0" aria-hidden />
<div className="flex flex-col w-48"><Label className="mb-1 h-3.5">Name</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</div>
<div className="flex flex-col w-36"><Label className="mb-1 h-3.5">QTH</Label>
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
</div>
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">Grid</Label>
<Input value={grid} placeholder="JN05" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</div>
<div className="flex flex-col w-40">
<Label className="mb-1 h-3.5 flex items-center gap-1.5">
Country
{flagURL(details.dxcc) && (
<img src={flagURL(details.dxcc)} alt="" className="h-3 rounded-[2px] border border-border/50 shadow-sm"
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
)}
</Label>
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
</div>
{/* DXCC # and Continent are derived from the callsign — read-only.
CQ/ITU stay editable but as plain text (no number spinners).
Kept compact (Log4OM-style) — just wide enough for their digits. */}
<div className="flex flex-col w-11"><Label className="mb-1 h-3.5">DXCC</Label>
<Input readOnly tabIndex={-1} className="font-mono bg-muted/40 cursor-default text-center px-1 text-xs"
value={details.dxcc ?? ''} placeholder="—" />
</div>
<div className="flex flex-col w-9"><Label className="mb-1 h-3.5">CQ</Label>
<Input inputMode="numeric" maxLength={2} className="font-mono text-center px-1 text-xs" value={details.cqz ?? ''} placeholder="—"
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
</div>
<div className="flex flex-col w-9"><Label className="mb-1 h-3.5">ITU</Label>
<Input inputMode="numeric" maxLength={2} className="font-mono text-center px-1 text-xs" value={details.ituz ?? ''} placeholder="—"
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
</div>
<div className="flex flex-col w-9"><Label className="mb-1 h-3.5">Cont</Label>
<Input readOnly tabIndex={-1} className="font-mono uppercase bg-muted/40 cursor-default text-center px-1 text-xs"
value={details.cont} placeholder="—" />
</div>
</>}
{/* ── Row 3: Freq + Band + Mode + Band RX + RX Freq ── */}
<div className="basis-full h-0" aria-hidden />
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1">
{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} <LockBtn k="freq" title="frequency" />
</Label>
<Input
tabIndex={-1}
className="font-mono"
value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
placeholder="14.250"
onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)}
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
/>
</div>
<div className="flex flex-col w-24">
<Label className="mb-1 h-3.5 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label>
<Select value={band} onValueChange={onBandUserChange}>
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1">Mode <LockBtn k="mode" title="mode" /></Label>
<Select value={mode} onValueChange={onModeUserChange}>
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
<SelectContent>{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col w-24">
<Label className="mb-1 h-3.5">Band RX</Label>
<Select value={bandRx} onValueChange={setBandRx}>
<SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
<SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col w-28">
<Label className={cn('mb-1 h-3.5', catState.split && 'text-rose-600')}>RX Freq (MHz)</Label>
<Input
tabIndex={-1}
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
placeholder="14.255"
onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)}
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
className={cn('font-mono', catState.split && 'bg-rose-50/40 border-rose-200 focus:bg-card')}
/>
</div>
{/* ── Row 4: Comment + Note ── */}
<div className="basis-full h-0" aria-hidden />
<div className="flex flex-col flex-1 min-w-[120px]"><Label className="mb-1 h-3.5">Comment</Label>
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
</div>
{!compact && (
<div className="flex flex-col flex-1 min-w-[120px]"><Label className="mb-1 h-3.5">Note</Label>
<Input value={note} onChange={(e) => setNote(e.target.value)} />
</div>
)}
<div className="flex flex-col ml-auto">
<Label className="mb-1 h-3.5">&nbsp;</Label>
<div className="flex gap-2">
{/* Send DX spot — only when a cluster is connected. Pre-fills the
dialog from the current entry (or the last logged QSO). */}
{clusterServerStatuses.some((s) => s.state === 'connected') && (
<Button
type="button"
variant="outline"
onClick={() => setShowSpotModal(true)}
className="h-8"
title="Send a DX spot to the master cluster"
>
<Satellite className="size-3.5" />
Spot
</Button>
)}
<Button onClick={save} disabled={saving} className="h-8">
<Send className="size-3.5" />
{saving ? '…' : 'Log QSO'}
</Button>
</div>
</div>
</section>
{/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail
tabs, then reserved free space. Hidden in compact mode. */}
{!compact && (
<div className="w-[560px] shrink-0 min-h-0 flex flex-col">
<DetailsPanel
callsign={callsign}
prefix={prefix}
operatorGrid={station.my_grid}
remoteGrid={grid}
details={details}
onChange={updateDetails}
wb={wb}
wbBusy={wbBusy}
band={band}
mode={mode}
/>
</div>
)}
{/* Reserved free space to the right — shows the QRZ profile photo large
so it's actually legible. Click opens the full-size image on QRZ. */}
{!compact && lookupResult?.image_url && (
<div className="flex-1 min-w-0 flex items-center">
<button
type="button"
onClick={() => lookupResult.image_url && OpenExternalURL(lookupResult.image_url).catch((err) => setError(String(err?.message ?? err)))}
className="rounded-lg border border-border overflow-hidden hover:border-primary/60 transition-colors bg-muted/20"
title="Open full-size on QRZ.com"
>
<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>{/* /entry + aside row */}
{/* ===== LOWER: tabs+table | call history | (optional) band map ===== */}
{compact ? null : <>
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]', showBandMap ? 'grid-cols-[1fr_260px]' : 'grid-cols-[1fr]')}>
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
<TabsList className="px-3 shrink-0">
<TabsTrigger value="main">Main</TabsTrigger>
<TabsTrigger value="recent">
Recent QSOs
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{qsos.length}</Badge>
</TabsTrigger>
<TabsTrigger value="cluster">Cluster</TabsTrigger>
<TabsTrigger value="worked">
Worked before
{wb && wb.count > 0 && (
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{wb.count}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="awards">Awards</TabsTrigger>
<TabsTrigger value="propagation">Propagation</TabsTrigger>
{qslTabOpen && (
<TabsTrigger value="qsl" className="gap-1.5">
QSL Manager
<span
role="button"
aria-label="Close QSL Manager"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeQslTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
</TabsList>
<TabsContent value="recent" className="mt-0 flex flex-col min-h-0 flex-1">
<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)}
/>
<Select value={filterBand || '_'} onValueChange={(v) => setFilterBand(v === '_' ? '' : v)}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All bands</SelectItem>
{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}
</SelectContent>
</Select>
<Select value={filterMode || '_'} onValueChange={(v) => setFilterMode(v === '_' ? '' : v)}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All modes</SelectItem>
{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
<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.duplicates > 0 && (
<Badge variant="outline" className="bg-white/60 font-mono text-sky-700 border-sky-300">{importResult.duplicates} duplicates</Badge>
)}
<Badge variant="outline" className="bg-white/60 font-mono text-amber-700 border-amber-300">{importResult.skipped} skipped</Badge>
<Badge variant="outline" className="bg-white/60 font-mono">{importResult.total} total</Badge>
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
<button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
{importDupsOpen ? 'Hide' : 'Show'} duplicates
{importResult.duplicates > importResult.duplicate_samples.length
? ` (first ${importResult.duplicate_samples.length} of ${importResult.duplicates})`
: ''}
</button>
)}
{importResult.errors && importResult.errors.length > 0 && (
<button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}>
{importErrorsOpen ? 'Hide' : 'Show'} {importResult.errors.length} error{importResult.errors.length > 1 ? 's' : ''}
</button>
)}
<button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
</div>
{importDupsOpen && importResult.duplicate_samples && (
<ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
{importResult.duplicate_samples.map((d, i) => <li key={i}>{d}</li>)}
</ul>
)}
{importErrorsOpen && importResult.errors && (
<ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
{importResult.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
)}
</div>
)}
<RecentQSOsGrid
rows={qsos as any}
total={total}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
onRowSelected={(id) => setSelectedId(id)}
/>
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
<span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{total}</span>
{filterCallsign || filterBand || filterMode ? ' (filtered)' : ''}
</span>
<div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && (
<span className="text-amber-700">Limit reached raise Max to see more.</span>
)}
<Label className="text-[11px] text-muted-foreground">Max</Label>
<Input
type="number"
min={1}
step={100}
className="w-24 h-7 font-mono text-xs"
value={qsoLimit}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n) && n > 0) setQsoLimit(Math.floor(n));
}}
/>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => setQsoLimit(Math.max(total, 1))}
title="Load the entire log"
disabled={total === 0}
>
All ({total})
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="cluster" className="mt-0 flex flex-row min-h-0 flex-1">
<div className="flex flex-col min-h-0 flex-1">
{/* Row 1: actions + per-server pills */}
<div className="flex items-center gap-2 px-2.5 pt-2.5 flex-wrap">
<Button
variant="outline" size="sm"
onClick={async () => { await ConnectAllClusters().catch((e) => setError(String(e?.message ?? e))); await reloadClusterMeta(); }}
>
Connect all
</Button>
<Button
variant="outline" size="sm"
onClick={async () => { await DisconnectAllClusters().catch(() => {}); await reloadClusterMeta(); }}
>
Disconnect all
</Button>
{clusterServerStatuses.length === 0 && (
<span className="text-xs text-muted-foreground italic">
No active sessions configure clusters in Settings DX Cluster.
</span>
)}
{clusterServerStatuses.map((s) => {
const isMaster = clusterServers
.filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
return (
<span
key={s.server_id}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border',
s.state === 'connected' ? 'bg-emerald-100 text-emerald-800 border-emerald-300' :
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' :
s.state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' :
'bg-muted text-muted-foreground border-border',
)}
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
>
{isMaster && <span className="text-amber-600" title="Master (commands go here)"></span>}
{s.name}
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span>
</span>
);
})}
<div className="flex-1" />
<Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
</div>
{/* Row 2: filters */}
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border/60 flex-wrap text-xs">
<Input
className="w-32 h-7 text-xs font-mono uppercase"
placeholder="Search call…"
value={clusterSearch}
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
/>
<span className="text-muted-foreground">Bands:</span>
{bands.map((b) => {
const on = clusterBands.has(b);
return (
<button
key={b}
type="button"
onClick={() => setClusterBands((s) => {
const n = new Set(s);
if (n.has(b)) n.delete(b); else n.add(b);
return n;
})}
className={cn(
'px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors',
on
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
)}
>
{b}
</button>
);
})}
{clusterBands.size > 0 && (
<button
type="button"
onClick={() => setClusterBands(new Set())}
className="text-[10px] text-muted-foreground hover:text-foreground underline"
title="Clear band filter"
>
clear
</button>
)}
<div className="w-px h-4 bg-border mx-1" />
<button
type="button"
onClick={() => setClusterLockBand((v) => !v)}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
clusterLockBand
? 'bg-amber-100 text-amber-800 border-amber-300'
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
)}
title="Only show spots on the entry strip's current band"
>
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
Lock band ({band})
</button>
<button
type="button"
onClick={() => setClusterLockMode((v) => !v)}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
clusterLockMode
? 'bg-amber-100 text-amber-800 border-amber-300'
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
)}
title="Only show spots whose mode matches the entry strip"
>
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
Lock mode ({mode})
</button>
<div className="w-px h-4 bg-border mx-1" />
<span className="text-muted-foreground">Status:</span>
{([
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
]).map((s) => {
const on = clusterStatusFilter.has(s.k);
return (
<button
key={s.k}
type="button"
onClick={() => setClusterStatusFilter((cur) => {
const n = new Set(cur);
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
return n;
})}
className={cn(
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
)}
>
{s.label}
</button>
);
})}
<div className="w-px h-4 bg-border mx-1" />
<span className="text-muted-foreground">Mode:</span>
{([
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
]).map((s) => {
const on = clusterModeFilter.has(s.k);
return (
<button
key={s.k}
type="button"
onClick={() => setClusterModeFilter((cur) => {
const n = new Set(cur);
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
return n;
})}
className={cn(
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
)}
>
{s.label}
</button>
);
})}
<div className="flex-1" />
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
Group
</label>
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
<SelectTrigger className="w-32 h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All sources</SelectItem>
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{(() => {
// Apply every filter. `bandsActive` is the band set the
// user clicked, OR the entry's locked band when Lock band
// is on. Mode lock compares the spot's inferred mode to
// the entry's current one.
const bandsActive = clusterLockBand
? new Set([band])
: clusterBands;
const search = clusterSearch.trim().toUpperCase();
let list = spots.filter((s) => {
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
if (search && !s.dx_call.includes(search)) return false;
if (clusterLockMode) {
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
if (spotMode && mode && spotMode !== mode) return false;
}
if (clusterModeFilter.size > 0) {
const cat = spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz));
if (!cat || !clusterModeFilter.has(cat)) return false;
}
if (clusterStatusFilter.size > 0) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const st = spotStatus[k]?.status || '';
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
}
return true;
});
let rendered = list as (ClusterSpot & { repeats?: number })[];
if (clusterGroup) {
const seen = new Map<string, ClusterSpot & { repeats: number }>();
for (const s of list) {
const e = seen.get(s.dx_call);
if (e) { e.repeats++; }
else seen.set(s.dx_call, { ...s, repeats: 1 });
}
rendered = Array.from(seen.values());
}
if (rendered.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
<div className="text-sm font-semibold text-foreground/70">
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
</div>
<div className="text-xs">
{clusterServerStatuses.some((s) => s.state === 'connected')
? 'Spots will appear as the cluster sends them.'
: 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
</div>
</div>
);
}
return (
<ClusterGrid
rows={rendered as any}
spotStatus={spotStatus}
onSpotClick={(s) => {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
SetCATFrequency(s.freq_hz).catch(() => {});
if (m) SetCATMode(m).catch(() => {});
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
if (m) setMode(m);
}
onCallsignInput(s.dx_call);
}}
/>
);
})()}
{/* Command input — sends to the master server. */}
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap"> master</span>
<Input
className="font-mono text-xs h-8"
placeholder='sh/dx 30, set/needsdxcc, …'
value={clusterCmd}
onChange={(e) => setClusterCmd(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && clusterCmd.trim()) {
SendClusterCommand(clusterCmd.trim())
.then(() => setClusterCmd(''))
.catch((err) => setError(String(err?.message ?? err)));
}
}}
/>
<Button
variant="outline" size="sm"
onClick={() => {
if (!clusterCmd.trim()) return;
SendClusterCommand(clusterCmd.trim())
.then(() => setClusterCmd(''))
.catch((err) => setError(String(err?.message ?? err)));
}}
disabled={!clusterCmd.trim()}
>
Send
</Button>
</div>
</div>{/* /left column */}
{/* BandMap moved to a global side panel below — toggle is
now in the topbar, visible on every tab. */}
</TabsContent>
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} />
</TabsContent>
{/* Opened on demand from Tools → QSL Manager; closable via the
tab's × . forceMount keeps its state (and a running download
updating) while you work on other tabs. */}
{qslTabOpen && (
<TabsContent value="qsl" forceMount className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<QSLManagerPanel />
</TabsContent>
)}
{(['main','awards','propagation'] as const).map((t) => (
<TabsContent key={t} value={t} className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
<div className="text-base font-semibold text-foreground/70">{t[0].toUpperCase() + t.slice(1)}</div>
<div className="text-xs">Module coming soon.</div>
</TabsContent>
))}
</Tabs>
</section>
{showBandMap && (
<div className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
<BandMap
band={band}
spots={spots.filter((s) => s.band === band)}
spotStatus={spotStatus}
currentFreqHz={freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={(s) => {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
SetCATFrequency(s.freq_hz).catch(() => {});
if (m) SetCATMode(m).catch(() => {});
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
if (m) setMode(m);
}
onCallsignInput(s.dx_call);
}}
onClose={() => setShowBandMap(false)}
/>
</div>
)}
</div>
</>}
{/* ===== STATUS BAR ===== */}
{!compact && (() => {
const clusterUp = clusterServerStatuses.some((s) => s.state === 'connected');
const catUp = catState.enabled && catState.connected;
const Chip = ({ on, label, title, onClick, disabled }: { on: boolean; label: React.ReactNode; title?: string; onClick?: () => void; disabled?: boolean }) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={cn(
'inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors',
disabled ? 'opacity-50 cursor-default border-transparent'
: 'border-border hover:bg-muted cursor-pointer',
)}
>
<span className={cn('size-2 rounded-full', on ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
{label}
</button>
);
return (
<footer className="flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
<span className="text-[11px] text-muted-foreground">
QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
</span>
<div className="w-px h-4 bg-border mx-1" />
<Chip on={clusterUp} label="Cluster" title={clusterUp ? 'Cluster connected' : 'Cluster offline'} onClick={() => setActiveTab('cluster')} />
<Chip
on={catUp}
label={<span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{catUp ? (catState.rig || 'CAT') : (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT')}</span>}
title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')}
onClick={() => { setSettingsSection('cat'); setShowSettings(true); }}
/>
<Chip
on={rotatorHeading.enabled && rotatorHeading.ok}
label={rotatorHeading.enabled && rotatorHeading.ok ? `Rotator ${rotatorHeading.azimuth}°` : 'Rotator'}
title={rotatorHeading.enabled ? (rotatorHeading.ok ? `Antenna heading ${rotatorHeading.azimuth}°` : 'Rotator: no position') : 'Rotator disabled'}
disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/>
<div className="flex-1" />
</footer>
);
})()}
{editingQSO && (
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} />
)}
<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(); }}
/>
)}
{deletingQSO && (
<ConfirmDialog
title="Delete QSO?"
message={`This will permanently delete the QSO with ${deletingQSO.callsign} on ${fmtDateUTC(deletingQSO.qso_date)} (${deletingQSO.band} ${deletingQSO.mode}). This cannot be undone.`}
confirmLabel="Delete"
danger
onConfirm={confirmDelete}
onCancel={() => setDeletingQSO(null)}
/>
)}
{showDeleteAll && (
<ConfirmDialog
title="Delete ALL QSOs?"
message={`This will permanently wipe every QSO from the logbook (${total.toLocaleString('en-US')} contacts). This cannot be undone. Consider exporting an ADIF backup first.`}
confirmLabel={deletingAll ? 'Deleting…' : `Delete ${total.toLocaleString('en-US')} QSOs`}
confirmPhrase="DELETE ALL"
danger
onConfirm={confirmDeleteAll}
onCancel={() => { if (!deletingAll) setShowDeleteAll(false); }}
/>
)}
{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-3">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={importSkipDups}
onCheckedChange={(c) => setImportSkipDups(!!c)}
className="mt-0.5"
/>
<span>
Skip duplicates
<span className="block text-xs text-muted-foreground mt-0.5">
Records that match an existing QSO on (callsign + UTC minute + band + mode) are not re-inserted. Uncheck to import everything as-is useful for merging two logs that overlap intentionally.
</span>
</span>
</label>
</div>
<DialogFooter className="px-2">
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
<Button onClick={runImport}>Import</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}