feat: added versionning & About window

This commit is contained in:
2026-06-16 19:36:56 +02:00
parent 33af122964
commit 69d0780bac
16 changed files with 1398 additions and 56 deletions
+47 -1
View File
@@ -31,11 +31,17 @@ export type AwardDef = {
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
or_rules?: AwardOrRule[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
total: number; builtin?: boolean;
};
type AwardOrRule = {
field: string; match_by?: string; exact_match?: boolean; pattern?: string;
leading_str?: string; trailing_str?: string; prefix?: string;
};
type AwardRef = {
code: string; name: string; dxcc: number; group: string; subgrp: string;
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
@@ -162,7 +168,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
if (!open) return;
setErr('');
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields(((f ?? []) as string[]).slice().sort((a, b) => a.localeCompare(b))); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
.catch((e) => setErr(String(e?.message ?? e)));
loadMeta();
}, [open]);
@@ -344,6 +350,46 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
</div>
{/* Additional OR searches: a QSO earns a reference if the
primary rule OR any of these match. */}
<div className="border-t pt-2.5 space-y-2">
<div className="flex items-center justify-between">
<p className="text-[11px] text-muted-foreground">Additional searches <span className="font-semibold">(OR)</span> also match the reference if any of these hit</p>
<Button size="sm" variant="outline" className="h-7"
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
<Plus className="size-3.5" /> Add OR
</Button>
</div>
{(cur.or_rules ?? []).map((r, ri) => {
const upd = (p: Partial<AwardOrRule>) => patch({ or_rules: (cur.or_rules ?? []).map((x, j) => (j === ri ? { ...x, ...p } : x)) });
const del = () => patch({ or_rules: (cur.or_rules ?? []).filter((_, j) => j !== ri) });
return (
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted-foreground">OR search in</span>
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
</Select>
<div className="flex items-center gap-2 text-[11px]">
{['code', 'description', 'pattern'].map((m) => (
<label key={m} className="flex items-center gap-1 cursor-pointer">
<input type="radio" name={`orby-${ri}`} checked={(r.match_by || 'code') === m} onChange={() => upd({ match_by: m })} className="accent-primary" /> {m}
</label>
))}
</div>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> exact</label>
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title="Remove this OR search"><Trash2 className="size-4" /></button>
</div>
<div className="grid grid-cols-[1fr_120px] gap-2">
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder="regex — group 1 = reference (e.g. \b(\d{2})\d{3}\b for postal → dept)" />
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" />
</div>
</div>
);
})}
</div>
</div>
</TabsContent>
+6 -2
View File
@@ -29,9 +29,13 @@ interface Props {
// of these and it's already filled (e.g. VE9CF → state NB), the award counts
// automatically — we surface that so the operator needn't pick it by hand.
fieldValues?: Record<string, string>;
// Height of the selector. Default is a fixed 210px (the QSO editor modal);
// the live entry panel passes "flex-1 min-h-0" so it fills the available
// space instead of overflowing and forcing a scrollbar.
heightClass?: string;
}
export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props) {
export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
const [defs, setDefs] = useState<AwardDef[]>([]);
const [metas, setMetas] = useState<Record<string, Meta>>({});
const [awardCode, setAwardCode] = useState('POTA');
@@ -172,7 +176,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
}
return (
<div className="flex gap-2 h-[210px]">
<div className={`flex gap-2 ${heightClass}`}>
{/* Left panel */}
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
+49 -3
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
@@ -121,6 +122,40 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided
// Live award detection: run the SAME engine used at log time over the current
// contact (callsign + looked-up address/state/zones) so award references the
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
// surfaced and auto-added the moment you enter a call or click a spot, instead
// of only appearing after logging. Pickable matches are merged into award_refs
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
useEffect(() => {
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
const t = window.setTimeout(async () => {
try {
const q: any = {
callsign, band, mode,
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
qso_date: new Date().toISOString(),
};
const all = ((await ComputeQSOAwardRefs(q)) ?? []) as any[];
setDetected(all as any);
const cur = details.award_refs ?? '';
const have = new Set(cur.split(';').filter(Boolean));
let next = cur;
for (const r of all) {
if (!r.pickable) continue;
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
}
if (next !== cur) onChange({ award_refs: next });
} catch { /* leave detection empty on failure */ }
}, 400);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
// Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes.
const path = useMemo(() => {
@@ -179,7 +214,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
))}
</nav>
<div className="overflow-y-auto min-h-0">
<div className="flex-1 overflow-y-auto min-h-0">
{open === 'stats' && (
<div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
@@ -257,13 +292,24 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
)}
{open === 'awards' && (
<div className="px-3 py-2.5">
<div className="px-3 py-2.5 h-full flex flex-col min-h-0">
<AwardRefSelector
dxcc={details.dxcc}
value={details.award_refs ?? ''}
onChange={(v) => onChange({ award_refs: v })}
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
heightClass="flex-1 min-h-0"
/>
{detected.length > 0 && (
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
<span className="font-medium text-foreground/70">Detected this contact will count for:</span>{' '}
{detected.map((r) => (
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
</span>
))}
</div>
)}
</div>
)}
+83 -6
View File
@@ -8,7 +8,7 @@ import {
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings,
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
@@ -445,6 +445,44 @@ function TelemetryToggle() {
);
}
// FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one
// (fills the IP/port). Self-contained so it can own its state (rendered inside
// the hook-less CATPanel).
function FlexDiscover({ onPick }: { onPick: (ip: string, port: number) => void }) {
const [busy, setBusy] = useState(false);
const [found, setFound] = useState<Array<{ ip: string; port: number; model?: string; nickname?: string }>>([]);
const [msg, setMsg] = useState('');
async function scan() {
setBusy(true); setMsg('');
try {
const r = ((await DiscoverFlexRadios()) ?? []) as any[];
setFound(r as any);
if (r.length === 0) setMsg('No radio found — check it\'s on the same network, or enter the IP manually.');
} catch (e: any) {
setMsg(String(e?.message ?? e));
} finally { setBusy(false); }
}
return (
<div className="rounded-md border border-border bg-muted/20 p-2 space-y-1.5">
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={scan} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Wifi className="size-3.5" />} Detect radios
</Button>
<span className="text-[11px] text-muted-foreground">listens for FlexRadio broadcast on the LAN</span>
</div>
{found.map((r) => (
<button key={r.ip} type="button" onClick={() => onPick(r.ip, r.port || 4992)}
className="w-full text-left text-xs rounded border border-border px-2 py-1 hover:bg-accent/50">
<span className="font-mono font-semibold">{r.ip}</span>
{r.model ? <span className="text-muted-foreground"> · {r.model}</span> : ''}
{r.nickname ? <span className="text-muted-foreground"> ({r.nickname})</span> : ''}
</button>
))}
{msg && <div className="text-[11px] text-muted-foreground">{msg}</div>}
</div>
);
}
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction;
@@ -492,7 +530,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
});
const [rotator, setRotator] = useState<RotatorSettings>({
@@ -1634,9 +1672,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<>
<SectionHeader
title="CAT interface"
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."
hint="Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug)."
/>
<div className="space-y-4 max-w-lg">
<div className="space-y-4 max-w-3xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
Enable CAT
@@ -1648,11 +1686,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="omnirig">OmniRig (Windows COM)</SelectItem>
<SelectItem value="flex" disabled>Flex SmartSDR (coming soon)</SelectItem>
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
</SelectContent>
</Select>
</div>
{catCfg.backend === 'omnirig' && (
<div className="space-y-1">
<Label>OmniRig rig slot</Label>
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
@@ -1663,6 +1702,30 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</SelectContent>
</Select>
</div>
)}
{catCfg.backend === 'flex' && (
<>
<div className="space-y-1">
<Label>FlexRadio IP</Label>
<Input placeholder="192.168.1.50" value={catCfg.flex_host ?? ''}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_host: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Port</Label>
<Input type="number" value={catCfg.flex_port || 4992}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} />
</div>
<div className="col-span-2">
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
</div>
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
Show cluster spots on the panadapter <span className="text-xs text-muted-foreground">(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)</span>
</label>
</>
)}
{catCfg.backend === 'omnirig' && (
<>
<div className="space-y-1">
<Label>Poll interval (ms)</Label>
<Input
@@ -1679,6 +1742,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
/>
</div>
</>
)}
<div className="space-y-1 col-span-2">
<Label>Default digital mode (when rig reports DIG)</Label>
<Select
@@ -1694,6 +1759,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</Select>
</div>
</div>
{catCfg.backend === 'omnirig' && (
<>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={catModeBeforeFreq}
@@ -1708,6 +1775,16 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
OmniRig only reports generic "DIG" for digital modes <strong>Default digital mode</strong>
{' '}is the specific mode OpsLog will surface (and log).
</p>
</>
)}
{catCfg.backend === 'flex' && (
<p className="text-xs text-muted-foreground">
Native SmartSDR API no OmniRig needed. Frequency, mode and split are read in
real time from the radio (no polling, no second-click mode bug). Use <strong>Detect
radios</strong> or enter the IP. <strong>Default digital mode</strong> is what OpsLog
logs when the slice is in a digital mode (DIGU/DIGL).
</p>
)}
</div>
</>
);
+24
View File
@@ -38,6 +38,10 @@ interface Props {
onToggleSendOnType: (on: boolean) => void;
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
onBackspace: () => void; // remove last not-yet-keyed char
autoCall: boolean; // repeat the clicked macro on a timer
autoCallSecs: number; // gap (s) after the message before repeating
onToggleAutoCall: (on: boolean) => void;
onSetAutoCallSecs: (n: number) => void;
}
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
@@ -48,6 +52,7 @@ export function WinkeyerPanel({
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
onSend, onSendMacro, onStop, onClose,
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
}: Props) {
const [cwText, setCwText] = useState('');
const [speed, setSpeed] = useState(wpm);
@@ -172,6 +177,25 @@ export function WinkeyerPanel({
</Button>
</div>
{/* Auto-call: repeat the clicked macro (e.g. F1 CQ) automatically until
someone answers. The seconds box is the gap AFTER the message. */}
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
title="After you click a macro (e.g. F1 CQ), resend it on a loop — message, then the gap, then repeat — until a callsign is entered or you press Stop">
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
onChange={(e) => onToggleAutoCall(e.target.checked)} />
Auto-call
</label>
<span className="text-[11px] text-muted-foreground">gap</span>
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="Seconds to wait after the message before resending">
<input type="number" min={0} max={120}
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
<span className="text-[9px] text-muted-foreground">sec</span>
</div>
{autoCall && <span className="text-[10px] text-amber-600/80">click a macro to loop it</span>}
</div>
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
<div className="grid grid-cols-3 gap-1">
{macros.map((m, i) => (