aduio mail

This commit is contained in:
2026-06-05 02:29:49 +02:00
parent a2a29c66d2
commit 95fdc1ccd1
14 changed files with 673 additions and 126 deletions
+68 -43
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, ExternalLink, Hash, Loader2, Lock,
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X,
} from 'lucide-react';
@@ -8,7 +8,7 @@ import {
AddQSO, ListQSO, CountQSO,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF,
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus,
WorkedBefore,
@@ -76,6 +76,9 @@ 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']);
const emptyDetails: DetailsState = {
state: '', cnty: '', address: '',
@@ -419,6 +422,16 @@ export default function App() {
setToast(msg);
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500);
}, []);
// Error banners auto-dismiss after a few seconds (longer than toasts since
// they may be multi-line). The X button still closes them immediately.
useEffect(() => {
if (!error) return;
const t = window.setTimeout(() => setError(''), 6000);
return () => window.clearTimeout(t);
}, [error]);
// True while the QSO recorder is capturing the current contact (set when we
// leave the callsign field, cleared on log/cancel). Drives the "REC" badge.
const [recording, setRecording] = useState(false);
const [saving, setSaving] = useState(false);
const [filterCallsign, setFilterCallsign] = useState('');
const [filterBand, setFilterBand] = useState('');
@@ -843,7 +856,6 @@ export default function App() {
const mine = myCallRef.current;
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
setSelfSpot({ spotter: cleanSpotter(sp.spotter ?? ''), freqKHz: sp.freq_khz, band: sp.band, comment: sp.comment, at: Date.now() });
showToast(`You've been spotted by ${cleanSpotter(sp.spotter ?? '') || '?'} on ${sp.freq_khz?.toFixed(1)} kHz`);
// Auto-hide 3 s after the last self-spot; a new one resets the timer.
if (selfSpotTimerRef.current) window.clearTimeout(selfSpotTimerRef.current);
selfSpotTimerRef.current = window.setTimeout(() => setSelfSpot(null), 3000);
@@ -878,7 +890,11 @@ export default function App() {
await LogUDPLoggedADIF(text);
await refresh();
} catch (e: any) {
setError('UDP auto-log: ' + String(e?.message ?? e));
const msg = String(e?.message ?? e);
// A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign —
// show a quiet toast, not a red error.
if (/duplicate/i.test(msg)) showToast('UDP QSO already logged — skipped');
else setError('UDP auto-log: ' + msg);
}
});
return () => { unsubDX?.(); unsubRC?.(); unsubProg?.(); unsubLog?.(); };
@@ -1048,7 +1064,7 @@ export default function App() {
function resetEntry() {
// Discard any in-progress QSO recording (no-op if it was already saved on
// log, or if the recorder is off).
QSOAudioCancel();
QSOAudioCancel(); setRecording(false);
setCallsign(''); setComment(''); setNote('');
if (!locks.start) setQsoStartedAt(null);
if (!locks.end) setQsoEndedAt(null);
@@ -1132,6 +1148,17 @@ export default function App() {
try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function bulkSendRecording(ids: number[]) {
if (ids.length === 0) return;
showToast(`Sending ${ids.length} recording${ids.length > 1 ? 's' : ''} by e-mail…`);
let ok = 0; const errs: string[] = [];
for (const id of ids) {
try { await SendQSORecordingEmail(id as any); ok++; }
catch (e: any) { errs.push(String(e?.message ?? e)); }
}
if (errs.length) setError(`Recording e-mail: ${ok} sent, ${errs.length} failed — ${errs[0]}`);
else showToast(`${ok} recording${ok > 1 ? 's' : ''} sent`);
}
// Right-click "Send to QRZ.com / Club Log / LoTW": uploads the selected QSOs
// on demand (regardless of their current upload status). Runs in the
// background; qslmgr:done refreshes the grid when finished.
@@ -1238,8 +1265,9 @@ export default function App() {
if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return;
// QSO recorder: a non-empty callsign marks the QSO start (the recorder
// keeps the pre-roll from before this); clearing it discards the take.
// Both are no-ops when the recorder is off.
if (v.trim() === '') QSOAudioCancel(); else QSOAudioBegin();
// Recording START happens on blur (leaving the callsign field), NOT here —
// you may type a call and work it minutes later. Clearing it cancels.
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); }
const wasEmpty = callsign.trim() === '';
const isEmpty = v.trim() === '';
if (wasEmpty && !isEmpty && !locks.start) {
@@ -1454,24 +1482,6 @@ export default function App() {
<div className="flex flex-col w-36">
<Label className="mb-1 flex items-center gap-2 h-3.5">
Callsign
{callsign.trim() && (
<button
type="button"
tabIndex={-1}
onClick={() => {
const c = callsign.trim().toUpperCase();
// Encode each segment but keep the '/' literal — QRZ's URL is
// /db/5Z4/MM0ZBH, not /db/5Z4%2FMM0ZBH (which 404s).
const path = c.split('/').map(encodeURIComponent).join('/');
OpenExternalURL(`https://www.qrz.com/db/${path}`)
.catch((err) => setError(String(err?.message ?? err)));
}}
title="Open this callsign on QRZ.com"
className="inline-flex items-center justify-center size-3.5 rounded text-muted-foreground/60 hover:text-primary transition-colors"
>
<ExternalLink className="size-3" />
</button>
)}
{lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up</Badge>}
{!lookupBusy && lookupResult && (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider" variant="outline">
@@ -1482,12 +1492,22 @@ export default function App() {
<Badge variant="destructive" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider">{lookupError}</Badge>
)}
</Label>
<div className="relative">
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[9px] font-semibold tracking-wider text-red-600 whitespace-nowrap pointer-events-none">
<span className="size-2 rounded-full bg-red-600 animate-pulse" />REC
</span>
)}
<Input
ref={callsignRef}
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
value={callsign}
onChange={(e) => onCallsignInput(e.target.value)}
// Start the QSO recording when leaving the callsign field (the pre-roll
// covers the seconds before). No-op when the recorder is off.
onBlur={() => { if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }}
/>
</div>
</div>
);
const rstTxBlock = (
@@ -1866,14 +1886,6 @@ export default function App() {
</header>
)}
{error && (
<div className="bg-destructive/10 text-destructive border-b border-destructive/30 px-4 py-2 flex items-start gap-3 text-xs">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<pre className="flex-1 font-mono whitespace-pre-wrap m-0">{error}</pre>
<button className="hover:text-destructive/70" onClick={() => setError('')}><X className="size-4" /></button>
</div>
)}
{/* QRZ profile photo lightbox — full size, in-app. Click anywhere or
press Esc to close; click the image itself doesn't close. */}
{photoModal && (
@@ -1899,12 +1911,24 @@ export default function App() {
</div>
)}
{/* Transient success toast (bottom-right). */}
{toast && (
<div className="fixed bottom-4 right-4 z-[100] flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
<Satellite className="size-4 shrink-0" />
<span>{toast}</span>
<button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
{/* Transient toasts (bottom-right). Errors stack on top of the green
success toast; both auto-dismiss. */}
{(error || toast) && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2 max-w-md">
{error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 text-destructive px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<pre className="flex-1 font-sans whitespace-pre-wrap m-0 leading-snug">{error}</pre>
<button className="ml-1 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3.5" /></button>
</div>
)}
{toast && (
<div className="flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
<Satellite className="size-4 shrink-0" />
<span>{toast}</span>
<button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
</div>
)}
</div>
)}
@@ -1913,7 +1937,7 @@ export default function App() {
so it never shifts the layout (push-down / spring-back) and never
covers the entry fields; auto-hides 3s after the last self-spot. */}
{!compact && selfSpot && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-100 text-amber-900 px-3.5 py-2 text-xs shadow-lg animate-in fade-in slide-in-from-bottom-2">
<div className="fixed bottom-16 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-100 text-amber-900 px-3.5 py-2 text-xs shadow-lg animate-in fade-in slide-in-from-bottom-2">
<RadioTower className="size-3.5 shrink-0" />
<span>
You've been spotted by <strong className="font-mono">{selfSpot.spotter || '?'}</strong>
@@ -2213,6 +2237,7 @@ export default function App() {
onUpdateFromQRZ={bulkUpdateFromQRZ}
onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo}
onSendRecording={bulkSendRecording}
onRowSelected={(id) => setSelectedId(id)}
/>
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
@@ -2544,7 +2569,7 @@ export default function App() {
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} />
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} />
</TabsContent>
{/* Opened on demand from Tools → QSL Manager; closable via the
@@ -2735,8 +2760,8 @@ export default function App() {
</div>
</button>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setShowExportChoice(false)}>Cancel</Button>
<DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setShowExportChoice(false)}>Cancel</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+16 -2
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck } from 'lucide-react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail } from 'lucide-react';
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
@@ -10,6 +10,7 @@ type Props = {
onUpdateFromQRZ: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
};
const UPLOAD_TARGETS: { service: string; label: string }[] = [
@@ -21,7 +22,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
// Lightweight right-click menu for the QSO grids. AG Grid's native context
// menu is an Enterprise feature, so this is a plain floating menu driven by
// onCellContextMenu. Closes on any outside click, scroll or Escape.
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo }: Props) {
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) {
useEffect(() => {
if (!menu) return;
const close = () => onClose();
@@ -77,6 +78,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
</button>
)}
{onSendRecording && (
<>
<div className="my-1 border-t border-border" />
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendRecording(menu.ids); onClose(); }}
>
<Mail className="size-4 text-rose-600" />
<span>Send recording by e-mail</span>
</button>
</>
)}
{onSendTo && (
<>
<div className="my-1 border-t border-border" />
+3 -1
View File
@@ -51,6 +51,7 @@ type Props = {
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
};
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
@@ -208,7 +209,7 @@ export const GROUP_ORDER = [
'Contest', 'Propagation', 'My station', 'Misc',
];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo }: Props) {
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -353,6 +354,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
/>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
+89 -25
View File
@@ -14,6 +14,7 @@ import {
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetEmailSettings, SaveEmailSettings, TestEmail,
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect,
@@ -136,6 +137,7 @@ interface Props {
`disabled: true` greys them out and shows the "coming soon" placeholder. */
type SectionId =
| 'general'
| 'email'
| 'station'
| 'profiles'
| 'operating'
@@ -172,6 +174,7 @@ const TREE: TreeNode[] = [
{
kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [
{ kind: 'item', label: 'General', id: 'general' },
{ kind: 'item', label: 'E-mail (SMTP)', id: 'email' },
{ kind: 'item', label: 'Callsign Lookup', id: 'lookup' },
{ kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
@@ -386,11 +389,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
qso_record: boolean; qso_dir: string; preroll_seconds: number;
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
from_gain: number; mic_gain: number;
};
type AudioDev = { id: string; name: string; default: boolean };
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
from_gain: 100, mic_gain: 100,
});
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
@@ -408,6 +413,18 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
// General behaviour prefs (machine-local, applied live via localStorage).
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
// E-mail / SMTP (send QSO recordings).
type EmailCfg = {
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
from: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
};
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
from: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
});
const [emailMsg, setEmailMsg] = useState('');
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
// ClubLog Country File (cty.xml) exception status.
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
@@ -569,6 +586,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
try { setWk(await GetWinkeyerSettings() as any); } catch {}
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
reloadAudioDevices();
reloadDvk();
} catch (e: any) {
@@ -726,6 +744,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
await SaveRotatorSettings(rotator as any);
await SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any);
await SaveEmailSettings(emailCfg as any);
await SaveBackupSettings(backupCfg as any);
await SaveQSLDefaults(qslDefaults as any);
await SaveExternalServices(extSvc as any);
@@ -2538,9 +2557,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<>
<div className="flex items-start justify-between">
<SectionHeader
title="Audio devices & voice keyer"
hint="Machine-local audio routing for the Digital Voice Keyer and the QSO recorder. Pick the soundcard endpoints wired to your rig. (Pure-Go WASAPI no extra driver.)"
/>
title="Audio devices & voice keyer"/>
<Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
Refresh devices
</Button>
@@ -2565,15 +2582,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
<h4 className="text-sm font-semibold text-foreground">QSO recorder</h4>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} className="mt-0.5" />
<span>
Record every QSO to an audio file
<span className="block text-xs text-muted-foreground mt-0.5">
Captures <strong>From Radio + your mic</strong> continuously into a rolling buffer; on <em>Log QSO</em> the
file is saved from a few seconds <em>before</em> you entered the callsign through the end of the contact.
</span>
</span>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} />
Record every QSO to an audio file (From Radio + your mic)
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Recordings folder</Label>
@@ -2597,19 +2608,28 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<SelectItem value="mp3">MP3 (compressed, small)</SelectItem>
</SelectContent>
</Select>
<Label className="text-sm">From Radio level</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
</div>
<Label className="text-sm">Mic level</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
Files are named <span className="font-mono">CALL_YYYYMMDD_HHMMSS.{audioCfg.format}</span>.
{audioCfg.format === 'mp3' ? ' MP3 ≈ 7× smaller — handy to send to correspondents.' : ' WAV is lossless (~115 KB/min).'}
</p>
<p className="text-xs text-muted-foreground">If your voice is louder than the station, lower Mic level.</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={emailCfg.auto_send} onCheckedChange={(c) => setEmailField({ auto_send: !!c })} />
Auto-send the recording to the station by e-mail when I log a QSO
</label>
</div>
<div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
<h4 className="text-sm font-semibold text-foreground">Voice keyer messages (F1F6)</h4>
<p className="text-[11px] text-muted-foreground">
<strong>Press and hold</strong> Rec while you speak (release to save). Preview on <strong>Listening</strong>;
during operation they transmit via <strong>To Radio</strong>.
</p>
<div className="rounded-md border border-border/60 p-2.5 space-y-2">
<div className="grid grid-cols-[120px_1fr] gap-2 items-center">
<Label className="text-sm">PTT method</Label>
@@ -2648,11 +2668,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</>
)}
</div>
<p className="text-[11px] text-muted-foreground">
<strong>CAT (OmniRig)</strong> keys TX through the rig control (sets OmniRig's Tx parameter) — needs CAT
connected. <strong>Serial RTS/DTR</strong> asserts a COM line (e.g. a SmartSDR CAT port set to PTT-on-RTS).
<strong> None (VOX)</strong> lets the rig key on audio. Use <strong>Test PTT</strong> to confirm.
</p>
</div>
{dvkErr && <p className="text-[11px] text-destructive">{dvkErr}</p>}
<div className="space-y-1.5">
@@ -2772,9 +2787,58 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
);
}
function EmailPanel() {
return (
<>
<SectionHeader title="E-mail"/>
<div className="space-y-3 max-w-2xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={emailCfg.enabled} onCheckedChange={(c) => setEmailField({ enabled: !!c })} />
Enable e-mail sending
</label>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">SMTP server</Label>
<Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
<Label className="text-sm">Port / encryption</Label>
<div className="flex gap-2 items-center">
<Input type="number" className="h-8 w-24 font-mono" value={emailCfg.smtp_port} onChange={(e) => setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} />
<Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="starttls">STARTTLS (587)</SelectItem>
<SelectItem value="ssl">SSL/TLS (465)</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
</Select>
</div>
<div />
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={emailCfg.auth} onCheckedChange={(c) => setEmailField({ auth: !!c })} />
SMTP requires authorization
</label>
<Label className="text-sm">Username</Label>
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
<Label className="text-sm">Password</Label>
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
<Label className="text-sm">From address</Label>
<Input className="h-8" placeholder="you@example.com" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="h-8"
onClick={() => { setEmailMsg('Sending test…'); SaveEmailSettings(emailCfg as any).then(() => TestEmail('')).then(() => setEmailMsg('Test e-mail sent ✓')).catch((e: any) => setEmailMsg('Test failed: ' + String(e?.message ?? e))); }}>
Send test e-mail
</Button>
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
</div>
</div>
</>
);
}
// Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = {
general: GeneralPanel,
email: EmailPanel,
station: StationPanel,
profiles: ProfilesPanel,
operating: OperatingPanelWrapper,
+3 -1
View File
@@ -51,6 +51,7 @@ type Props = {
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
};
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
@@ -63,7 +64,7 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo }: Props) {
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -236,6 +237,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
/>
{count > entries.length && (
+9 -1
View File
@@ -97,6 +97,8 @@ export function GetDVKStatus():Promise<main.DVKStatus>;
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
export function GetEmailSettings():Promise<main.EmailSettings>;
export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetListsSettings():Promise<main.ListsSettings>;
@@ -167,7 +169,7 @@ export function PickOpenDatabase():Promise<string>;
export function PickSaveDatabase():Promise<string>;
export function QSOAudioBegin():Promise<void>;
export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>;
@@ -199,6 +201,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
@@ -225,6 +229,8 @@ export function SendClusterCommand(arg1:string):Promise<void>;
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
export function SendQSORecordingEmail(arg1:number):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
@@ -243,6 +249,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
export function TestClublogUpload():Promise<string>;
export function TestEmail(arg1:string):Promise<void>;
export function TestLoTWUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
+16
View File
@@ -170,6 +170,10 @@ export function GetDatabaseSettings() {
return window['go']['main']['App']['GetDatabaseSettings']();
}
export function GetEmailSettings() {
return window['go']['main']['App']['GetEmailSettings']();
}
export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices']();
}
@@ -374,6 +378,10 @@ export function SaveClusterServer(arg1) {
return window['go']['main']['App']['SaveClusterServer'](arg1);
}
export function SaveEmailSettings(arg1) {
return window['go']['main']['App']['SaveEmailSettings'](arg1);
}
export function SaveExternalServices(arg1) {
return window['go']['main']['App']['SaveExternalServices'](arg1);
}
@@ -426,6 +434,10 @@ export function SendClusterSpot(arg1, arg2, arg3) {
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
}
export function SendQSORecordingEmail(arg1) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
}
export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1);
}
@@ -462,6 +474,10 @@ export function TestClublogUpload() {
return window['go']['main']['App']['TestClublogUpload']();
}
export function TestEmail(arg1) {
return window['go']['main']['App']['TestEmail'](arg1);
}
export function TestLoTWUpload() {
return window['go']['main']['App']['TestLoTWUpload']();
}
+36
View File
@@ -385,6 +385,8 @@ export namespace main {
ptt_method: string;
ptt_port: string;
format: string;
from_gain: number;
mic_gain: number;
static createFrom(source: any = {}) {
return new AudioSettings(source);
@@ -402,6 +404,8 @@ export namespace main {
this.ptt_method = source["ptt_method"];
this.ptt_port = source["ptt_port"];
this.format = source["format"];
this.from_gain = source["from_gain"];
this.mic_gain = source["mic_gain"];
}
}
export class BackupSettings {
@@ -534,6 +538,38 @@ export namespace main {
this.is_custom = source["is_custom"];
}
}
export class EmailSettings {
enabled: boolean;
smtp_host: string;
smtp_port: number;
smtp_user: string;
smtp_password: string;
from: string;
encryption: string;
auth: boolean;
auto_send: boolean;
subject: string;
body: string;
static createFrom(source: any = {}) {
return new EmailSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.smtp_host = source["smtp_host"];
this.smtp_port = source["smtp_port"];
this.smtp_user = source["smtp_user"];
this.smtp_password = source["smtp_password"];
this.from = source["from"];
this.encryption = source["encryption"];
this.auth = source["auth"];
this.auto_send = source["auto_send"];
this.subject = source["subject"];
this.body = source["body"];
}
}
export class ModePreset {
name: string;
default_rst_sent?: string;