chore: release v021.4

This commit is contained in:
2026-07-27 00:16:17 +02:00
parent aefb984974
commit cb27aa5ebf
13 changed files with 457 additions and 35 deletions
+101 -16
View File
@@ -3,7 +3,7 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil,
} from 'lucide-react';
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -35,7 +35,7 @@ import {
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus,
@@ -1178,7 +1178,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
});
const [emailMsg, setEmailMsg] = useState('');
const [showSmtpPass, setShowSmtpPass] = useState(false);
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
// eQSL card e-mail (subject/body templates + auto-send on log).
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
@@ -1223,20 +1222,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
type ExtServiceCfg = {
api_key: string; email: string; username: string; password: string; callsign: string;
code: string; qth_nickname: string;
url: string; station_id: string; // Cloudlog/Wavelog: own instance + station profile
force_station_callsign: string;
tqsl_path: string; station_location: string; key_password: string;
upload_flags: string[]; write_log: boolean;
auto_upload: boolean; upload_mode: string;
};
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg };
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg };
const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
upload_flags: ['N', 'R'], write_log: false,
auto_upload: false, upload_mode: 'immediate',
});
const [extSvc, setExtSvc] = useState<ExternalServices>({
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(),
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(),
});
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [qrzTesting, setQrzTesting] = useState(false);
@@ -1299,12 +1299,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
};
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hrdlogTesting, setHrdlogTesting] = useState(false);
const [cloudlogTest, setCloudlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [cloudlogTesting, setCloudlogTesting] = useState(false);
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [eqslTesting, setEqslTesting] = useState(false);
const [stationLocations, setStationLocations] = useState<string[]>([]);
// Active tab in the External Services panel — lifted here because
// PANELS[selected]() is called as a function, so panels can't hold hooks.
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'pota'>('qrz');
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'pota'>('qrz');
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
const [potaToken, setPotaToken] = useState('');
const [potaBusy, setPotaBusy] = useState(false);
@@ -3756,6 +3758,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{ k: 'hrdlog', label: 'HRDLOG.NET', ready: true },
{ k: 'eqsl', label: 'EQSL', ready: true },
{ k: 'lotw', label: 'LOTW', ready: true },
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
{ k: 'pota', label: 'POTA', ready: true },
];
@@ -3825,6 +3828,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
}
const cloudlog = extSvc.cloudlog;
const setCloudlog = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, cloudlog: { ...s.cloudlog, ...patch } }));
async function testCloudlog() {
setCloudlogTesting(true);
setCloudlogTest(null);
try {
// Save first: the backend test reads the stored config, so it must see
// what was just typed (same as the other services' Test buttons).
await SaveExternalServices(extSvc as any);
const msg = await TestCloudlogUpload();
setCloudlogTest({ ok: true, msg });
} catch (e: any) {
setCloudlogTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
setCloudlogTesting(false);
}
}
const eqsl = extSvc.eqsl;
const setEqsl = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } }));
@@ -4077,6 +4100,74 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</div>
</div>
) : extSvcTab === 'cloudlog' ? (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">{t('es.cloudlogUrl')}</Label>
<Input
value={cloudlog.url}
onChange={(e) => setCloudlog({ url: e.target.value })}
placeholder={t('es.cloudlogUrlPh')}
className="font-mono text-xs"
/>
<Label className="text-sm">{t('es.apiKey')}</Label>
<Input
type="password"
value={cloudlog.api_key}
onChange={(e) => setCloudlog({ api_key: e.target.value })}
placeholder={t('es.cloudlogKeyPh')}
className="text-xs"
/>
<Label className="text-sm">{t('es.cloudlogStationId')}</Label>
<Input
value={cloudlog.station_id}
onChange={(e) => setCloudlog({ station_id: e.target.value })}
placeholder="1"
className="font-mono text-xs w-24"
/>
</div>
<div className="text-[10px] text-muted-foreground -mt-1">
{t('es.cloudlogHint')}
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={cloudlog.auto_upload}
onCheckedChange={(c) => setCloudlog({ auto_upload: !!c })}
/>
{t('es.autoUpload')}
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">{t('es.uploadTiming')}</Label>
<Select
value={cloudlog.upload_mode === 'delayed' ? 'delayed' : 'immediate'}
onValueChange={(v) => setCloudlog({ upload_mode: v })}
>
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* No "on close" option: unlike the other services, Cloudlog has no
per-QSO status column in the logbook, so there is nothing to
select a backlog from at shutdown. */}
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={testCloudlog} disabled={cloudlogTesting}>
<UploadCloud className="size-3.5" /> {cloudlogTesting ? t('es.testing') : t('es.testConn')}
</Button>
{cloudlogTest && (
<span className={cn('text-xs', cloudlogTest.ok ? 'text-success' : 'text-danger')}>
{cloudlogTest.msg}
</span>
)}
</div>
</div>
</div>
) : extSvcTab === 'eqsl' ? (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
@@ -4992,15 +5083,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label className="text-sm">{t('em.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">{t('es.password')}</Label>
<div className="relative">
<Input type={showSmtpPass ? 'text' : 'password'} className="h-8 pr-9" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
<button type="button" tabIndex={-1} onClick={() => setShowSmtpPass((v) => !v)}
title={showSmtpPass ? t('es.hidePass') : t('es.showPass')}
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
disabled={!emailCfg.auth}>
{showSmtpPass ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
{/* No reveal button: the settings window is often open while the
screen is shared or shown to someone in the shack. */}
<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">{t('em.fromAddr')}</Label>
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
<Label className="text-sm">{t('em.replyTo')}</Label>