feat: added versionning & About window
This commit is contained in:
+114
-15
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user