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
+114 -15
View File
@@ -39,6 +39,7 @@ import type { adif as adifModels, lookup as lookupModels, cat as catModels } fro
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
import { Menubar, type Menu } from '@/components/Menubar';
import { APP_VERSION, APP_AUTHOR } from '@/version';
import { QSLManagerPanel } from '@/components/QSLManagerModal';
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
@@ -90,9 +91,10 @@ 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'];
// Modes the QSO recorder captures (voice + CW). Mirrors recordableMode() in
// app.go — digital modes carry no useful audio and are never recorded.
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV','CW']);
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
// app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex,
// so neither is recorded (no REC badge / timer for them).
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);
const emptyDetails: DetailsState = {
state: '', cnty: '', address: '',
@@ -564,6 +566,16 @@ export default function App() {
const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
// Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds)
// until a reply is entered or it's stopped. Persisted as UI prefs.
const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1');
const [wkAutoCallSecs, setWkAutoCallSecs] = useState(() => Number(localStorage.getItem('opslog.wkAutoCallSecs')) || 3);
const wkAutoCallRef = useRef(wkAutoCall);
const wkAutoCallSecsRef = useRef(wkAutoCallSecs);
const autoCallGenRef = useRef(0); // bump to cancel the running loop
const autoCallMacroRef = useRef(-1); // macro index currently auto-repeating (-1 = none)
useEffect(() => { wkAutoCallRef.current = wkAutoCall; }, [wkAutoCall]);
useEffect(() => { wkAutoCallSecsRef.current = wkAutoCallSecs; }, [wkAutoCallSecs]);
// F1-F12 macro shortcuts active only when the keyer is enabled + connected.
const wkActiveRef = useRef(false);
const wkEscClearsRef = useRef(true);
@@ -665,6 +677,7 @@ export default function App() {
// close so the next plain "Preferences" launch reverts to default.
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
const [showDeleteAll, setShowDeleteAll] = useState(false);
const [showAbout, setShowAbout] = useState(false);
const [deletingAll, setDeletingAll] = useState(false);
const [ctyRefreshing, setCtyRefreshing] = useState(false);
const [refsDownloading, setRefsDownloading] = useState(false);
@@ -1092,13 +1105,27 @@ export default function App() {
}
if (!lk.band && s.band) setBand(s.band);
// Mode resolution priority: digital watering-hole → CAT's DATA → CAT mode.
// Mode resolution.
// FlexRadio reports the exact mode for voice/CW (USB/LSB/CW…) → trust it.
// But all digital sub-modes share one radio mode (DIGU/DIGL → "DATA"), so
// when it's digital we still pick FT8/FT4/RTTY from the frequency's
// watering hole (e.g. 14.080 → FT4), else the operator's default.
// OmniRig & other rigs often can't tell digital from SSB on a digital
// freq, so for them we infer from the frequency regardless of reported mode.
if (!lk.mode) {
const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
let nextMode = '';
if (inferred) nextMode = inferred;
else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8';
else if (s.mode) nextMode = s.mode;
if (s.backend === 'flex') {
if (s.mode === 'DATA') {
nextMode = (s.freq_hz ? inferDigitalMode(s.freq_hz) : '') || digitalDefaultRef.current || 'FT8';
} else if (s.mode) {
nextMode = s.mode;
}
} else {
const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
if (inferred) nextMode = inferred;
else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8';
else if (s.mode) nextMode = s.mode;
}
if (nextMode) {
setMode(nextMode);
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits)
@@ -1191,6 +1218,12 @@ export default function App() {
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? ''));
});
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
const call = String(p?.call ?? '');
if (applyUdpCall(call)) restartRecordingForNewTarget(call);
});
const unsubProg = EventsOn('import:progress', (p: any) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
});
@@ -1208,7 +1241,7 @@ export default function App() {
else setError('UDP auto-log: ' + msg);
}
});
return () => { unsubDX?.(); unsubRC?.(); unsubProg?.(); unsubLog?.(); };
return () => { unsubDX?.(); unsubRC?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -1307,8 +1340,41 @@ export default function App() {
for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish
void save();
}
function wkSendMacro(i: number) { const m = wkMacros[i]; if (m) wkSend(m.text); }
// stopAutoCall cancels any running auto-call loop.
function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; }
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
// gap, then resends — looping until cancelled (reply entered, Stop, unchecked).
async function runAutoCall(i: number) {
const gen = ++autoCallGenRef.current;
autoCallMacroRef.current = i;
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
while (autoCallMacroRef.current === i && gen === autoCallGenRef.current && wkActiveRef.current) {
const m = wkMacros[i];
if (!m) break;
await wkSend(m.text);
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
for (let k = 0; k < 2400 && wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤120s to finish
if (gen !== autoCallGenRef.current) break;
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
}
}
function wkSendMacro(i: number) {
const m = wkMacros[i];
if (!m) return;
if (wkAutoCallRef.current) runAutoCall(i); // loop this macro until a reply / stop
else wkSend(m.text);
}
wkSendMacroRef.current = wkSendMacro;
function wkToggleAutoCall(on: boolean) {
setWkAutoCall(on);
writeUiPref('opslog.wkAutoCall', on ? '1' : '0');
if (!on) stopAutoCall();
}
function wkSetAutoCallSecs(n: number) {
const v = Math.max(0, Math.min(120, n || 0));
setWkAutoCallSecs(v);
writeUiPref('opslog.wkAutoCallSecs', String(v));
}
// send-on-type: key the typed chars verbatim (no variable substitution).
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
function wkBackspace() { WinkeyerBackspace().catch(() => {}); }
@@ -1660,6 +1726,9 @@ export default function App() {
// still replace it. Without this, clicking a cluster spot froze the call:
// applyUdpCall saw current != lastUdpCall and refused every later UDP call.
if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
// A callsign appeared (someone answered the CQ, or a spot was clicked) →
// stop auto-calling so we don't key over the contact.
if (v.trim() !== '') stopAutoCall();
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
// on every status packet. If it matches what's already in the entry,
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
@@ -1777,7 +1846,7 @@ export default function App() {
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
]},
{ name: 'help', label: 'Help', items: [
{ type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true },
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
]},
], [total, selectedId, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]);
@@ -1797,6 +1866,7 @@ export default function App() {
case 'tools.dvk': setDvkEnabled((v) => !v); break;
case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); break;
}
}
@@ -2196,7 +2266,7 @@ export default function App() {
<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>
<span className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
</div>
<Menubar menus={menus} onAction={handleMenu} />
@@ -2438,6 +2508,26 @@ export default function App() {
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
)}
{showAbout && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowAbout(false)}>
<div className="w-full max-w-sm rounded-xl border border-border bg-card shadow-2xl p-6 text-center animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-2 mb-1">
<div className="size-3 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
<h2 className="text-xl font-bold tracking-tight">OpsLog</h2>
</div>
<p className="text-sm text-muted-foreground">Ham-radio logbook</p>
<p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p>
<p className="mt-3 text-sm">
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
</p>
<p className="mt-1 text-[11px] text-muted-foreground">73 & good DX</p>
<button onClick={() => setShowAbout(false)} className="mt-5 h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">
Close
</button>
</div>
</div>
)}
{/* Transient toasts (bottom-right). Errors stack on top of the green
success toast; both auto-dismiss. */}
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
@@ -2557,7 +2647,11 @@ export default function App() {
{/* 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">
// relative + absolute inner: the panel's content can't grow the row, so
// the row height is set by the ENTRY STRIP. A taller tab (Awards/F3) then
// scrolls inside this fixed height instead of pushing everything down.
<div className="w-[560px] shrink-0 min-h-0 relative">
<div className="absolute inset-0 flex flex-col min-h-0">
<DetailsPanel
callsign={callsign}
prefix={prefix}
@@ -2574,6 +2668,7 @@ export default function App() {
onTab={setDetailTab}
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
/>
</div>
</div>
)}
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
@@ -2608,7 +2703,7 @@ export default function App() {
</div>
)}
{wkEnabled && (
<div className="w-[500px] shrink-0 min-h-0">
<div className="w-[380px] shrink-0 min-h-0">
<WinkeyerPanel
status={wkStatus}
ports={wkPorts}
@@ -2623,12 +2718,16 @@ export default function App() {
onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
onSend={wkSend}
onSendMacro={wkSendMacro}
onStop={() => WinkeyerStop().catch(() => {})}
onStop={() => { stopAutoCall(); WinkeyerStop().catch(() => {}); }}
onClose={() => wkSetEnabled(false)}
sendOnType={wkSendOnType}
onToggleSendOnType={wkToggleSendOnType}
onSendRaw={wkSendRaw}
onBackspace={wkBackspace}
autoCall={wkAutoCall}
autoCallSecs={wkAutoCallSecs}
onToggleAutoCall={wkToggleAutoCall}
onSetAutoCallSecs={wkSetAutoCallSecs}
/>
</div>
)}