feat: Station Control amp parity + all amps, Help→Send log to F4BPO, bulk-edit frequency, fix Cluster midnight sort
- Station Control: the amplifier panel is now the exact same card as the FlexRadio panel (extracted shared AmpCard: OPERATE/STANDBY, ON/OFF, power level, output-power bar, live FWD/ID/temp meters). Every configured amp gets its own card — no dropdown. - Help → "Send log to F4BPO" (only when SMTP is configured): e-mails opslog.log + previous session + crash log to the developer. New email.SendFiles for multi-attachment. - Bulk edit: new "Frequency (MHz)" field sets freq_hz AND recomputes band; out-of-band values rejected. Fixes a run logged on a stale/default freq after CAT dropped. - Cluster: Time column sorted lexically on the HHMMZ string, so spots after 0000Z sorted below 2359Z and looked frozen at the UTC rollover. Now sorts by real arrival time. - Also folds in the DVK auto-CQ/2-column layout and Station Control dashboard batch (changelog 0.20.10, EN+FR).
This commit is contained in:
+93
-7
@@ -14,6 +14,7 @@ import {
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
||||
SMTPConfigured, SendLogToDeveloper,
|
||||
WorkedBefore,
|
||||
SetCompactMode,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||
@@ -959,8 +960,10 @@ export default function App() {
|
||||
const dvkActiveRef = useRef(false);
|
||||
const dvkPlayingRef = useRef(false);
|
||||
const dvkPlayRef = useRef<(slot: number) => void>(() => {});
|
||||
const dvkMsgsRef = useRef<DVKMsg[]>([]);
|
||||
useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]);
|
||||
useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]);
|
||||
useEffect(() => { dvkMsgsRef.current = dvkMsgs; }, [dvkMsgs]);
|
||||
useEffect(() => {
|
||||
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
|
||||
return () => { off?.(); };
|
||||
@@ -972,7 +975,48 @@ export default function App() {
|
||||
reloadDvk();
|
||||
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
||||
}, [dvkEnabled, reloadDvk]);
|
||||
const dvkPlay = useCallback((slot: number) => { DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e))); }, []);
|
||||
// DVK auto-CQ: repeat a CQ voice message on a timer, like the CW keyer's
|
||||
// auto-call. Only slots whose LABEL contains "CQ" loop; any other slot plays
|
||||
// once (and stops a running loop), so a report doesn't keep repeating.
|
||||
const [dvkAutoCq, setDvkAutoCq] = useState(() => localStorage.getItem('opslog.dvkAutoCq') === '1');
|
||||
const [dvkAutoCqSecs, setDvkAutoCqSecs] = useState(() => Number(localStorage.getItem('opslog.dvkAutoCqSecs')) || 3);
|
||||
const dvkAutoCqRef = useRef(dvkAutoCq);
|
||||
const dvkAutoCqSecsRef = useRef(dvkAutoCqSecs);
|
||||
const dvkAutoCqGenRef = useRef(0);
|
||||
const dvkAutoCqSlotRef = useRef(-1);
|
||||
useEffect(() => { dvkAutoCqRef.current = dvkAutoCq; localStorage.setItem('opslog.dvkAutoCq', dvkAutoCq ? '1' : '0'); }, [dvkAutoCq]);
|
||||
useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]);
|
||||
// The DVK is a VOICE keyer — transmitting it on CW/FT8/RTTY would key the rig
|
||||
// with speech on a data slot. Only allow it on phone modes.
|
||||
const isPhoneMode = (m: string) => RECORDABLE_MODES.has((m || '').toUpperCase());
|
||||
const modeRef = useRef(mode);
|
||||
useEffect(() => { modeRef.current = mode; }, [mode]);
|
||||
function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
|
||||
async function runDvkAutoCq(slot: number) {
|
||||
const gen = ++dvkAutoCqGenRef.current;
|
||||
dvkAutoCqSlotRef.current = slot;
|
||||
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||
while (dvkAutoCqSlotRef.current === slot && gen === dvkAutoCqGenRef.current && dvkActiveRef.current) {
|
||||
if (!isPhoneMode(modeRef.current)) { stopDvkAutoCq(); break; }
|
||||
await DVKPlay(slot).catch(() => {});
|
||||
await sleep(300); // let playback flip "playing" true
|
||||
let guard = 0; // then wait for it to finish (cap ~90 s)
|
||||
while (dvkPlayingRef.current && gen === dvkAutoCqGenRef.current && guard < 600) { await sleep(150); guard++; }
|
||||
if (gen !== dvkAutoCqGenRef.current) break;
|
||||
await sleep(Math.max(0, dvkAutoCqSecsRef.current) * 1000); // gap before the next CQ
|
||||
}
|
||||
}
|
||||
const dvkPlay = useCallback((slot: number) => {
|
||||
if (!isPhoneMode(modeRef.current)) { setError(t('dvkp.notPhone')); return; }
|
||||
const m = dvkMsgsRef.current.find((x) => x.slot === slot);
|
||||
const isCQ = (m?.label || '').toUpperCase().includes('CQ');
|
||||
if (dvkAutoCqRef.current && isCQ) {
|
||||
runDvkAutoCq(slot); // loop this CQ until Stop / a non-CQ slot / mode change
|
||||
} else {
|
||||
stopDvkAutoCq();
|
||||
DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e)));
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]);
|
||||
// Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch
|
||||
// it from the keyboard without clashing with the F1-F12 keyer macros).
|
||||
@@ -1190,6 +1234,15 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
|
||||
}, []);
|
||||
// Whether an SMTP server is configured — gates the "Send log to F4BPO" help
|
||||
// entry. Re-checked when the Settings modal closes (the user may have just set
|
||||
// it up), so the entry appears without a restart.
|
||||
const [smtpConfigured, setSmtpConfigured] = useState(false);
|
||||
const [sendingLog, setSendingLog] = useState(false);
|
||||
useEffect(() => {
|
||||
if (showSettings) return; // re-check after closing Settings
|
||||
SMTPConfigured().then((v: any) => setSmtpConfigured(!!v)).catch(() => {});
|
||||
}, [showSettings]);
|
||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
@@ -2869,9 +2922,15 @@ export default function App() {
|
||||
]},
|
||||
{ name: 'help', label: t('menu.help'), items: [
|
||||
{ type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' },
|
||||
// Only when SMTP is set up: e-mail the diagnostic logs to the developer.
|
||||
...(smtpConfigured ? [
|
||||
{ type: 'separator' as const },
|
||||
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
|
||||
] : []),
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||
]},
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, t]);
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
@@ -2900,6 +2959,21 @@ export default function App() {
|
||||
case 'tools.downloadRefs': downloadRefs(); break;
|
||||
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
|
||||
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
||||
case 'help.sendlog': sendLogToDeveloper(); break;
|
||||
}
|
||||
}
|
||||
|
||||
// E-mail the diagnostic logs to the developer (Help → Send log to F4BPO).
|
||||
async function sendLogToDeveloper() {
|
||||
if (sendingLog) return;
|
||||
setSendingLog(true);
|
||||
try {
|
||||
await SendLogToDeveloper();
|
||||
showToast(t('help.sendLogOk'));
|
||||
} catch (e: any) {
|
||||
showToast(t('help.sendLogFail', { err: String(e?.message ?? e) }));
|
||||
} finally {
|
||||
setSendingLog(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2943,8 +3017,10 @@ export default function App() {
|
||||
// callsign depends on the "ESC clears callsign" option; with the keyer
|
||||
// off it always resets the entry (the classic behaviour).
|
||||
if (e.key === 'Escape') {
|
||||
// If a voice message is transmitting, ESC just stops it (keeps entry).
|
||||
if (dvkActiveRef.current && dvkPlayingRef.current) {
|
||||
// If a voice message is transmitting (or auto-CQ is looping), ESC stops it
|
||||
// and cancels the loop (keeps entry).
|
||||
if (dvkActiveRef.current && (dvkPlayingRef.current || dvkAutoCqSlotRef.current >= 0)) {
|
||||
stopDvkAutoCq();
|
||||
DVKStop();
|
||||
e.preventDefault();
|
||||
return;
|
||||
@@ -4420,7 +4496,11 @@ export default function App() {
|
||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||
otherwise it shows the QRZ profile photo. */}
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
||||
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
||||
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
||||
// the entry strip and each widget fills that height, scrolling inside.
|
||||
<div className="flex-1 min-w-0 min-h-0 relative">
|
||||
<div className="absolute inset-0 flex gap-2.5 items-stretch">
|
||||
{/* Multi-op "who's on air" widget: every operator on the shared logbook,
|
||||
their freq/mode (colour-coded) and OpsLog version. */}
|
||||
{showLiveStations && dbConn?.backend === 'mysql' && (
|
||||
@@ -4503,13 +4583,18 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{dvkEnabled && (
|
||||
<div className="w-[264px] shrink-0 min-h-0">
|
||||
<div className="w-[320px] shrink-0 min-h-0">
|
||||
<DvkPanel
|
||||
messages={dvkMsgs}
|
||||
status={dvkStat}
|
||||
onPlay={dvkPlay}
|
||||
onStop={() => DVKStop()}
|
||||
onStop={() => { stopDvkAutoCq(); DVKStop(); }}
|
||||
onClose={() => setDvkEnabled(false)}
|
||||
autoCq={dvkAutoCq}
|
||||
autoCqSecs={dvkAutoCqSecs}
|
||||
onToggleAutoCq={setDvkAutoCq}
|
||||
onSetAutoCqSecs={(n) => setDvkAutoCqSecs(Math.max(0, Math.min(120, n || 0)))}
|
||||
phoneOk={isPhoneMode(mode)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -4574,6 +4659,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>{/* /entry + aside row */}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user