Initial codebase: Go + Wails amateur radio logbook
Backend (Go 1.25 / Wails v2): - QSO storage on SQLite (modernc) with embedded migrations (0001..0005) - Streaming ADIF import (batch insert) + WorkedBefore per callsign and DXCC - Callsign lookup with QRZ.com + HamQTH providers (primary/failsafe routing) and SQLite-backed TTL cache - DXCC resolver from cty.dat (auto-download, longest-prefix-match) - Multi-profile operator identities (home/portable/SOTA/contest) — every QSO stamps MY_* from the active profile - CAT control via OmniRig COM on a single OS-locked goroutine, with bidirectional sync (freq/mode/band/split/VFOs) and Rig1/Rig2 hot-swap - Settings store (key/value), CAT debug log at %APPDATA%/HamLog/cat.log Frontend (React 18 + TypeScript + Tailwind v4 + shadcn-style): - Single-row entry strip with CAT-aware band/mode/freq, RST, Start/End UTC, per-field locks (band/mode/freq/start/end) for backdated QSOs - Topbar: live freq (MHz.kHz.Hz dotted), live UTC, band/mode/SPLIT badges, CAT pill with rig selector and clickable Azimuth pill (rotor TODO) - Settings tree: Profiles (Log4OM-style manager), Station Information (edits the active profile), unified Callsign Lookup with Test buttons, Bands/Modes lists, CAT - Worked-before matrix (band × mode × class) with new-DXCC highlighting - ADIF import from menu + Maintenance > Refresh cty.dat Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronUp, Construction } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { pathBetween } from '@/lib/maidenhead';
|
||||
|
||||
export interface DetailsState {
|
||||
state: string;
|
||||
cnty: string;
|
||||
address: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
// DXCC entity number + zones (filled from QRZ/HamQTH or cty.dat fallback).
|
||||
// Editable so the operator can correct an obviously wrong auto-fill.
|
||||
dxcc?: number;
|
||||
cqz?: number;
|
||||
ituz?: number;
|
||||
cont: string;
|
||||
qsl_msg: string;
|
||||
qsl_via: string;
|
||||
ant_az?: number;
|
||||
ant_el?: number;
|
||||
ant_path: string;
|
||||
prop_mode: string;
|
||||
my_rig: string;
|
||||
my_antenna: string;
|
||||
tx_pwr?: number;
|
||||
sat_name: string;
|
||||
sat_mode: string;
|
||||
contest_id: string;
|
||||
srx?: number;
|
||||
stx?: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
callsign: string;
|
||||
prefix: string;
|
||||
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
||||
remoteGrid: string; // entry-strip Grid value — destination
|
||||
details: DetailsState;
|
||||
onChange: (patch: Partial<DetailsState>) => void;
|
||||
}
|
||||
|
||||
type TabName = 'info' | 'awards' | 'my' | 'extended';
|
||||
|
||||
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||
|
||||
function numOrUndef(v: string): number | undefined {
|
||||
if (v === '') return undefined;
|
||||
const n = parseFloat(v);
|
||||
return isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
// Compact field helper to keep the JSX dense.
|
||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 6 && 'col-span-6')}>
|
||||
<Label className="mb-1">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange }: Props) {
|
||||
const [open, setOpen] = useState<TabName | null>(null);
|
||||
|
||||
// Bearing/distance from operator's home grid to the remote station.
|
||||
// Recomputed only when either grid actually changes.
|
||||
const path = useMemo(
|
||||
() => pathBetween(operatorGrid, remoteGrid),
|
||||
[operatorGrid, remoteGrid],
|
||||
);
|
||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||
|
||||
function toggle(t: TabName) { setOpen((prev) => (prev === t ? null : t)); }
|
||||
|
||||
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
|
||||
function setSatellite(on: boolean) {
|
||||
if (on) {
|
||||
if (details.prop_mode !== 'SAT') onChange({ prop_mode: 'SAT' });
|
||||
} else {
|
||||
onChange({
|
||||
sat_name: '', sat_mode: '',
|
||||
...(details.prop_mode === 'SAT' ? { prop_mode: '' } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { key: TabName; label: string }[] = [
|
||||
{ key: 'info', label: 'Info (F2)' },
|
||||
{ key: 'awards', label: 'Awards (F3)' },
|
||||
{ key: 'my', label: 'My (F4)' },
|
||||
{ key: 'extended', label: 'Extended (F5)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="border-b border-border bg-card shrink-0">
|
||||
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => toggle(t.key)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs font-medium border-b-2 border-transparent -mb-px transition-colors',
|
||||
open === t.key
|
||||
? 'text-primary border-primary font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
{open && (
|
||||
<button
|
||||
onClick={() => setOpen(null)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground p-1.5"
|
||||
title="Close"
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{open === 'info' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="State / pref">
|
||||
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="County">
|
||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Prefix">
|
||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||
</Field>
|
||||
{/* DXCC #, CQ zone, ITU zone, Continent and Azimuth SP live in the
|
||||
main entry strip — visible without opening F2. F2 keeps the
|
||||
less-needed long-path bearing and both distances. */}
|
||||
<Field label="Azimuth LP">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtDeg(path.bearingLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Distance SP">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceShort) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Distance LP">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="font-mono bg-muted/40 cursor-default"
|
||||
value={path ? fmtKm(path.distanceLong) : ''}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Address" span={3}>
|
||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="QSL message" span={3}>
|
||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="QSL via" span={2}>
|
||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open === 'awards' && (
|
||||
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
<Construction className="size-6 mx-auto mb-2 text-muted-foreground/60" />
|
||||
<div className="font-semibold text-sm text-foreground/70">Awards module coming soon</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open === 'my' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="Ant. azimuth (°)">
|
||||
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Ant. elevation (°)">
|
||||
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Ant. path">
|
||||
<Input value={details.ant_path} placeholder="S / L / G" onChange={(e) => onChange({ ant_path: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Propagation">
|
||||
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === 'NONE' ? '—' : p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="TX power (W)">
|
||||
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<div className="flex items-end pb-1.5">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
||||
Satellite mode
|
||||
</label>
|
||||
</div>
|
||||
<Field label="Rig" span={3}>
|
||||
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Antenna" span={3}>
|
||||
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||
</Field>
|
||||
{satelliteMode && (
|
||||
<>
|
||||
<Field label="Satellite name" span={3}>
|
||||
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Satellite mode" span={3}>
|
||||
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open === 'extended' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label="Contest ID" span={2}>
|
||||
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="SRX">
|
||||
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="STX">
|
||||
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Contacted email" span={3}>
|
||||
<Input value={details.email} placeholder="op@example.com" onChange={(e) => onChange({ email: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user