@@ -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 - [ 11 px ] 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 - 2 xl ">
<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 - [ 170 px_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 - [ 11 px ] 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 - 2 xl ">
<h4 className=" text - sm font - semibold text - foreground ">Voice keyer messages (F1– F6)</h4>
<p className=" text - [ 11 px ] 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 - [ 120 px_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 - [ 11 px ] 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 - [ 11 px ] 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 - 2 xl ">
<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 - [ 130 px_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 - [ 11 px ] 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,