This commit is contained in:
2026-05-28 21:32:46 +02:00
parent e8cac569e3
commit e82e30dd02
29 changed files with 2485 additions and 97 deletions
+205 -12
View File
@@ -16,6 +16,8 @@ import {
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetQSLDefaults, SaveQSLDefaults,
ComputeStationInfo,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -35,6 +37,7 @@ import {
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { OperatingPanel } from '@/components/OperatingPanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm;
@@ -98,7 +101,7 @@ const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [
const emptyProfile = (): Profile => ({
id: 0,
name: '',
callsign: '', operator: '',
callsign: '', operator: '', owner_callsign: '',
my_grid: '', my_country: '',
my_state: '', my_cnty: '',
my_street: '', my_city: '', my_postal_code: '',
@@ -117,6 +120,45 @@ interface Props {
onSaved: () => void;
}
// Pretty little card showing what OpsLog will stamp on each QSO based on
// the callsign + grid in the Station Information form. Debounces the
// backend resolver so we don't fire on every keystroke; refreshes when
// inputs change. Empty card when no callsign yet.
function StationInfoComputedBadge({ callsign, grid }: { callsign: string; grid: string }) {
const [info, setInfo] = useState<{
country: string; dxcc: number; cqz: number; ituz: number; lat: number; lon: number;
} | null>(null);
useEffect(() => {
const c = callsign.trim();
if (!c) { setInfo(null); return; }
const t = window.setTimeout(async () => {
try {
const i = await ComputeStationInfo(c, grid.trim());
setInfo(i as any);
} catch { setInfo(null); }
}, 200);
return () => window.clearTimeout(t);
}, [callsign, grid]);
if (!info || (!info.country && !info.cqz && !info.ituz)) {
return null;
}
return (
<div className="rounded-md border border-primary/30 bg-primary/5 p-2.5">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1.5">
Auto-filled on each QSO (MY_*)
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px] font-mono">
{info.country && <span><span className="text-muted-foreground">Country:</span> <strong>{info.country}</strong></span>}
{info.dxcc > 0 && <span><span className="text-muted-foreground">DXCC#:</span> <strong>{info.dxcc}</strong></span>}
{info.cqz > 0 && <span><span className="text-muted-foreground">CQ:</span> <strong>{info.cqz}</strong></span>}
{info.ituz > 0 && <span><span className="text-muted-foreground">ITU:</span> <strong>{info.ituz}</strong></span>}
{info.lat !== 0 && <span><span className="text-muted-foreground">Lat:</span> <strong>{info.lat.toFixed(4)}</strong></span>}
{info.lon !== 0 && <span><span className="text-muted-foreground">Lon:</span> <strong>{info.lon.toFixed(4)}</strong></span>}
</div>
</div>
);
}
/* ====== Tree definition ======
Section IDs are stable strings — adding new ones means adding a panel below.
`disabled: true` greys them out and shows the "coming soon" placeholder. */
@@ -124,6 +166,8 @@ type SectionId =
| 'station'
| 'profiles'
| 'operating'
| 'confirmations'
| 'udp'
| 'lookup'
| 'lists-bands'
| 'lists-modes'
@@ -145,6 +189,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'Station Information', id: 'station' },
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
{ kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' },
{ kind: 'item', label: 'Confirmations (QSL / eQSL / LoTW defaults)', id: 'confirmations' },
],
},
{
@@ -155,6 +200,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
]},
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
{ kind: 'item', label: 'Database backup', id: 'backup' },
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
],
@@ -174,11 +220,13 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
station: 'Station Information',
profiles: 'Profiles',
operating: 'Operating conditions',
confirmations: 'Confirmations',
lookup: 'Callsign Lookup',
'lists-bands': 'Bands',
'lists-modes': 'Modes & default RST',
cluster: 'DX Cluster',
backup: 'Database backup',
udp: 'UDP integrations',
awards: 'Awards',
cat: 'CAT interface',
rotator: 'Rotator',
@@ -316,6 +364,19 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
type QSLDefaults = {
qsl_sent: string; qsl_rcvd: string;
lotw_sent: string; lotw_rcvd: string;
eqsl_sent: string; eqsl_rcvd: string;
clublog_status: string; hrdlog_status: string;
};
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
qsl_sent: '', qsl_rcvd: '',
lotw_sent: '', lotw_rcvd: '',
eqsl_sent: '', eqsl_rcvd: '',
clublog_status: '', hrdlog_status: '',
});
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
enabled: false, folder: '', rotation: 5, zip: false,
last_backup_at: '', default_folder: '',
@@ -389,9 +450,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
useEffect(() => {
(async () => {
try {
const [l, ls, c, ap, r, b] = await Promise.all([
const [l, ls, c, ap, r, b, qd] = await Promise.all([
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
GetRotatorSettings(), GetBackupSettings(),
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(),
]);
setLookup(l);
setActiveProfile(ap as Profile);
@@ -401,6 +462,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setCatCfg(c);
setRotator(r);
setBackupCfg(b as any);
setQslDefaults(qd as any);
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
@@ -519,6 +581,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
await SaveCATSettings(catCfg as any);
await SaveRotatorSettings(rotator as any);
await SaveBackupSettings(backupCfg as any);
await SaveQSLDefaults(qslDefaults as any);
await SetClusterAutoConnect(clusterAutoConnect);
setMsg('Settings saved.');
@@ -577,11 +640,19 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<div className="space-y-1">
<Label>Station callsign</Label>
<Input className="font-mono uppercase" value={p.callsign ?? ''} onChange={(e) => updateActive({ callsign: e.target.value })} placeholder="F4XYZ" />
<div className="text-[10px] text-muted-foreground">What's transmitted (ADIF STATION_CALLSIGN).</div>
</div>
<div className="space-y-1">
<Label>Operator</Label>
<Label>Operator callsign</Label>
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
<div className="text-[10px] text-muted-foreground">Who's at the radio (ADIF OPERATOR).</div>
</div>
<div className="space-y-1 col-span-2">
<Label>Owner callsign</Label>
<Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
<div className="text-[10px] text-muted-foreground">Legal station owner only differs at club stations or remote setups (ADIF STATION_OWNER).</div>
</div>
<div className="col-span-2"><StationInfoComputedBadge callsign={p.callsign ?? ''} grid={p.my_grid ?? ''} /></div>
<div className="space-y-1">
<Label>My grid</Label>
<Input className="font-mono uppercase" value={p.my_grid ?? ''} onChange={(e) => updateActive({ my_grid: e.target.value })} placeholder="JN18BU" />
@@ -1106,7 +1177,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<>
<SectionHeader
title="CAT interface (OmniRig)"
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig HamLog just talks to it."
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig OpsLog just talks to it."
/>
<div className="space-y-4 max-w-lg">
<label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -1168,10 +1239,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div>
<p className="text-xs text-muted-foreground">
Configure your rig (COM port, baud rate, model) in OmniRig's own settings GUI first.
HamLog will read whichever Rig slot you select here. Set <strong>CAT delay</strong>
OpsLog will read whichever Rig slot you select here. Set <strong>CAT delay</strong>
{' '}above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu).
OmniRig only reports generic "DIG" for digital modes — <strong>Default digital mode</strong>
{' '}is the specific mode HamLog will surface (and log).
{' '}is the specific mode OpsLog will surface (and log).
</p>
</div>
</>
@@ -1196,7 +1267,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<>
<SectionHeader
title="Rotator (PstRotator)"
hint="HamLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing."
hint="OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing."
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -1402,6 +1473,126 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
);
}
function ConfirmationsPanel() {
// ADIF status codes. FULL set for paper QSL/eQSL/Clublog/HRDLog,
// SIMPLE Y/N set for LoTW (the only values the LoTW protocol returns).
const FULL_OPTIONS = [
{ value: '_', label: ' leave blank ' },
{ value: 'Y', label: 'Y (yes)' },
{ value: 'N', label: 'N (no)' },
{ value: 'R', label: 'R (requested)' },
{ value: 'Q', label: 'Q (queued)' },
{ value: 'I', label: 'I (ignore)' },
];
// LoTW / Clublog / HRDLog also use ADIF-style status codes — keep
// R (requested) available so users can mark "queued for upload"
// and filter on it later.
// Renderer inlined as a constant — declaring this as a function
// INSIDE ConfirmationsPanel would re-instantiate the component on
// every render, which unmounts and re-mounts the Radix Select
// (closing it the moment you click the trigger).
const renderSelect = (
key: keyof QSLDefaults,
options: { value: string; label: string }[],
) => (
<Select
value={qslDefaults[key] || '_'}
onValueChange={(v) => setQslDefaults((d) => ({ ...d, [key]: v === '_' ? '' : v }))}
>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
);
return (
<>
<SectionHeader
title="Confirmations"
hint="Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty."
/>
<div className="space-y-3 max-w-2xl">
{/* Paper QSL */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">Paper QSL</Label>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
{renderSelect('qsl_sent', FULL_OPTIONS)}
</div>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
{renderSelect('qsl_rcvd', FULL_OPTIONS)}
</div>
</div>
{/* eQSL */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">eQSL</Label>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
{renderSelect('eqsl_sent', FULL_OPTIONS)}
</div>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
{renderSelect('eqsl_rcvd', FULL_OPTIONS)}
</div>
</div>
{/* LoTW */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">LoTW</Label>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
{renderSelect('lotw_sent', FULL_OPTIONS)}
</div>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
{renderSelect('lotw_rcvd', FULL_OPTIONS)}
</div>
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<div className="text-[11px] text-muted-foreground">
Upload status fields (Clublog / HRDLog) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded.
</div>
{/* Clublog */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">Clublog</Label>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
{renderSelect('clublog_status', FULL_OPTIONS)}
</div>
<div />
</div>
{/* HRDLog */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">HRDLog</Label>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
{renderSelect('hrdlog_status', FULL_OPTIONS)}
</div>
<div />
</div>
</div>
</div>
</>
);
}
function UDPIntegrationsPanelWrapper() {
return (
<>
<SectionHeader
title="UDP integrations"
hint="Listen for QSO logs from WSJT-X / JTDX / MSHV, ADIF messages from JTAlert/GridTracker, or simple callsign packets from external tools. Outbound connections forward every QSO you log to a remote listener (Cloudlog UDP, N1MM, …)."
/>
<UDPIntegrationsPanel onError={(m) => setErr(m)} />
</>
);
}
function OperatingPanelWrapper() {
return (
<>
@@ -1445,7 +1636,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<>
<SectionHeader
title="Database backup"
hint="HamLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."
hint="OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -1453,7 +1644,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
checked={!!backupCfg.enabled}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, enabled: !!c }))}
/>
<span>Automatic backup when closing HamLog (max once per day)</span>
<span>Automatic backup when closing OpsLog (max once per day)</span>
</label>
<div className="space-y-1.5">
@@ -1483,7 +1674,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<div className="text-[10px] text-muted-foreground">
{backupCfg.folder
? <>Effective folder: <span className="font-mono">{effectiveFolder}</span></>
: <>If empty, HamLog uses the default: <span className="font-mono">{(backupCfg.default_folder ?? '').replace(/\\/g, '/')}</span></>
: <>If empty, OpsLog uses the default: <span className="font-mono">{(backupCfg.default_folder ?? '').replace(/\\/g, '/')}</span></>
}
</div>
</div>
@@ -1541,10 +1732,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
station: StationPanel,
profiles: ProfilesPanel,
operating: OperatingPanelWrapper,
confirmations: ConfirmationsPanel,
lookup: LookupPanel,
'lists-bands': BandsPanel,
'lists-modes': ModesPanel,
cluster: ClusterPanel,
udp: UDPIntegrationsPanelWrapper,
backup: BackupPanel,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
@@ -1558,7 +1751,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader>
<DialogTitle>Preferences</DialogTitle>
<DialogDescription className="sr-only">Configure HamLog modules — station, lookup, hardware…</DialogDescription>
<DialogDescription className="sr-only">Configure OpsLog modules — station, lookup, hardware…</DialogDescription>
</DialogHeader>
{loading ? (