2252 lines
105 KiB
TypeScript
2252 lines
105 KiB
TypeScript
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"> </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>
|
||
);
|
||
}
|