aduio mail
This commit is contained in:
+68
-43
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 (F1–F6)</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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Vendored
+9
-1
@@ -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>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user