feat: Winkeyer
This commit is contained in:
@@ -11,12 +11,13 @@ import {
|
||||
GetCATSettings, SaveCATSettings,
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||||
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||
ConnectClusterServer, DisconnectClusterServer,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp,
|
||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
@@ -146,6 +147,7 @@ type SectionId =
|
||||
| 'awards'
|
||||
| 'cat'
|
||||
| 'rotator'
|
||||
| 'winkeyer'
|
||||
| 'antenna'
|
||||
| 'audio';
|
||||
|
||||
@@ -172,8 +174,7 @@ const TREE: TreeNode[] = [
|
||||
]},
|
||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
|
||||
{ kind: 'item', label: 'Database backup', id: 'backup' },
|
||||
{ kind: 'item', label: 'Database location', id: 'database' },
|
||||
{ kind: 'item', label: 'Database', id: 'database' },
|
||||
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
||||
],
|
||||
},
|
||||
@@ -181,6 +182,7 @@ const TREE: TreeNode[] = [
|
||||
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
||||
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
||||
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
||||
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
|
||||
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
||||
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
|
||||
],
|
||||
@@ -199,11 +201,12 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
'lists-modes': 'Modes & default RST',
|
||||
cluster: 'DX Cluster',
|
||||
backup: 'Database backup',
|
||||
database: 'Database location',
|
||||
database: 'Database',
|
||||
udp: 'UDP integrations',
|
||||
awards: 'Awards',
|
||||
cat: 'CAT interface',
|
||||
rotator: 'Rotator',
|
||||
winkeyer: 'CW Keyer (WinKeyer)',
|
||||
antenna: 'Antenna',
|
||||
audio: 'Audio devices',
|
||||
};
|
||||
@@ -284,6 +287,21 @@ function SectionHeader({ title, hint }: { title: string; hint?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ProfileScopeNote flags that the panel's settings are saved per-profile, so
|
||||
// the user knows which operating identity (F4BPO / TM2Q / …) they're editing.
|
||||
function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: string } }) {
|
||||
return (
|
||||
<div className="-mt-2 mb-4">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 text-primary text-xs px-2.5 py-1">
|
||||
<User className="size-3.5" />
|
||||
Saved for profile <strong className="font-semibold">{profile?.name || '—'}</strong>
|
||||
{profile?.callsign ? <span className="font-mono opacity-80">({profile.callsign})</span> : null}
|
||||
— switch profiles to edit another identity.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||
const label = SECTION_LABELS[id] ?? id;
|
||||
const IconCmp = Icon ?? Construction;
|
||||
@@ -340,17 +358,37 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
// WinKeyer CW keyer settings + macro editor.
|
||||
type WKMac = { label: string; text: string };
|
||||
type WKSettings = {
|
||||
enabled: boolean; engine: string; esc_clears_call: boolean;
|
||||
port: string; baud: number; wpm: number; weight: number;
|
||||
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
|
||||
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
|
||||
use_ptt: boolean; serial_echo: boolean; macros: WKMac[];
|
||||
};
|
||||
const [wk, setWk] = useState<WKSettings>({
|
||||
enabled: false, engine: 'winkeyer', esc_clears_call: true,
|
||||
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
|
||||
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
|
||||
swap: false, autospace: true, use_ptt: false, serial_echo: true, macros: [],
|
||||
});
|
||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||
|
||||
type QSLDefaults = {
|
||||
qsl_sent: string; qsl_rcvd: string;
|
||||
lotw_sent: string; lotw_rcvd: string;
|
||||
eqsl_sent: string; eqsl_rcvd: string;
|
||||
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
|
||||
qrzcom_confirmed: string;
|
||||
};
|
||||
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
||||
qsl_sent: '', qsl_rcvd: '',
|
||||
lotw_sent: '', lotw_rcvd: '',
|
||||
eqsl_sent: '', eqsl_rcvd: '',
|
||||
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
||||
qrzcom_confirmed: '',
|
||||
});
|
||||
|
||||
// External services (logbook upload). One block per service; only QRZ is
|
||||
@@ -483,6 +521,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const locs: any = await ListTQSLStationLocations();
|
||||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
||||
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
||||
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -636,6 +676,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
await SaveLookupSettings(lookup as any);
|
||||
await SaveCATSettings(catCfg as any);
|
||||
await SaveRotatorSettings(rotator as any);
|
||||
await SaveWinkeyerSettings(wk as any);
|
||||
await SaveBackupSettings(backupCfg as any);
|
||||
await SaveQSLDefaults(qslDefaults as any);
|
||||
await SaveExternalServices(extSvc as any);
|
||||
@@ -788,7 +829,17 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
|
||||
async function profileActivate() {
|
||||
if (!currentProfile) return;
|
||||
try { await ActivateProfile(currentProfile.id as number); await reloadProfiles(); onSaved(); }
|
||||
try {
|
||||
await ActivateProfile(currentProfile.id as number);
|
||||
await reloadProfiles();
|
||||
// Per-profile settings follow the active identity — reload the panels
|
||||
// that are now scoped to the newly-active profile.
|
||||
const [ap, qd, es] = await Promise.all([GetActiveProfile(), GetQSLDefaults(), GetExternalServices()]);
|
||||
setActiveProfile(ap as Profile);
|
||||
setQslDefaults(qd as any);
|
||||
setExtSvc(es as any);
|
||||
onSaved();
|
||||
}
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function profileRemove() {
|
||||
@@ -1436,6 +1487,153 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function WinkeyerPanel() {
|
||||
const setMacro = (i: number, patch: Partial<WKMac>) => setWk((s) => {
|
||||
const macros = [...s.macros];
|
||||
while (macros.length <= i) macros.push({ label: '', text: '' });
|
||||
macros[i] = { ...macros[i], ...patch };
|
||||
return { ...s, macros };
|
||||
});
|
||||
const num = (v: string, d: number) => { const n = parseInt(v, 10); return isNaN(n) ? d : n; };
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="CW Keyer (WinKeyer)"
|
||||
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
|
||||
/>
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wk.enabled} onCheckedChange={(c) => setWkField({ enabled: !!c })} />
|
||||
Enable CW keyer (shows the keyer panel)
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label>Keyer engine</Label>
|
||||
<Select value={wk.engine} onValueChange={(v) => setWkField({ engine: v })}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
||||
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
|
||||
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
|
||||
ESC clears the callsign too (otherwise ESC only stops transmission)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Serial port</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— COM port —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" title="Reload ports"
|
||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Baud</Label>
|
||||
<Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1200, 9600].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Speed (WPM)</Label>
|
||||
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Weight</Label>
|
||||
<Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Lead-in (ms)</Label>
|
||||
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Tail (ms)</Label>
|
||||
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Ratio (33-66)</Label>
|
||||
<Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Farnsworth</Label>
|
||||
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Sidetone (Hz)</Label>
|
||||
<Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Paddle mode</Label>
|
||||
<Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="iambic_b">Iambic B</SelectItem>
|
||||
<SelectItem value="iambic_a">Iambic A</SelectItem>
|
||||
<SelectItem value="ultimatic">Ultimatic</SelectItem>
|
||||
<SelectItem value="bug">Bug</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> Swap paddles
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> Auto-space
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> Key PTT
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Macro editor */}
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<Label className="text-sm font-medium">CW message macros (F1…)</Label>
|
||||
<p className="text-[11px] text-muted-foreground mb-2">
|
||||
Use variables: <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call.
|
||||
</p>
|
||||
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const m = wk.macros[i] ?? { label: '', text: '' };
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] text-primary font-semibold w-6 shrink-0">F{i + 1}</span>
|
||||
<Input className="h-8 w-28 shrink-0 text-xs" placeholder="Label" value={m.label} onChange={(e) => setMacro(i, { label: e.target.value })} />
|
||||
<Input className="h-8 flex-1 font-mono text-xs" placeholder="CQ CQ DE <MY_CALL> K" value={m.text} onChange={(e) => setMacro(i, { text: e.target.value })} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function statusForServer(id: number): ClusterServerStatus | undefined {
|
||||
return clusterStatuses.find((s) => (s.server_id as number) === id);
|
||||
}
|
||||
@@ -1623,6 +1821,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
title="Confirmations"
|
||||
hint="Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty."
|
||||
/>
|
||||
<ProfileScopeNote profile={activeProfileObj} />
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
{/* Paper QSL */}
|
||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||
@@ -1690,7 +1889,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
|
||||
{renderSelect('qrzcom_status', FULL_OPTIONS)}
|
||||
</div>
|
||||
<div />
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Confirmed</Label>
|
||||
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1923,6 +2125,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
hint="Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first)."
|
||||
/>
|
||||
|
||||
<ProfileScopeNote profile={activeProfileObj} />
|
||||
|
||||
{/* Tab strip */}
|
||||
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
||||
{TABS.map((t) => (
|
||||
@@ -2100,6 +2304,19 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-sm">Force station callsign</Label>
|
||||
<div>
|
||||
<Input
|
||||
value={lotw.force_station_callsign}
|
||||
onChange={(e) => setLotw({ force_station_callsign: e.target.value.toUpperCase() })}
|
||||
placeholder="e.g. F4BPO/P — leave blank to use the QSO's own call"
|
||||
className="font-mono uppercase w-64"
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls
|
||||
(F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.
|
||||
</div>
|
||||
</div>
|
||||
<Label className="text-sm">Key password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
@@ -2184,6 +2401,15 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function createNew() {
|
||||
try {
|
||||
const p = await PickSaveDatabase();
|
||||
if (!p) return;
|
||||
await CreateDatabase(p);
|
||||
await refreshDb();
|
||||
setDbMsg(`New empty logbook created at:\n${p}\nand selected. Restart OpsLog to apply.`);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function resetDefault() {
|
||||
try {
|
||||
await ResetDatabaseToDefault();
|
||||
@@ -2194,8 +2420,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Database location"
|
||||
hint="Keep your log database wherever you like — another drive or a synced folder (Seafile, Dropbox…) — so it survives a Windows reinstall. Everything (QSOs, settings, lookup cache) lives in this one file."
|
||||
title="Database"
|
||||
hint="Your whole log (QSOs, settings, lookup cache) lives in one SQLite file. Keep it wherever you like — another drive or a synced folder (Seafile, Dropbox…) — and back it up automatically."
|
||||
/>
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="space-y-1">
|
||||
@@ -2210,15 +2436,17 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={openExisting}>Open existing database…</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy & switch to it…</Button>
|
||||
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> New database…</Button>
|
||||
<Button variant="outline" size="sm" onClick={openExisting}>Open existing…</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy & switch…</Button>
|
||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
||||
<strong>Open existing</strong> points OpsLog at a database file you already have (e.g. after reinstalling Windows).{' '}
|
||||
<strong>Save a copy</strong> writes the current database to a new place and switches to it.{' '}
|
||||
A database change takes effect on the next launch.
|
||||
<strong>New</strong> creates a fresh empty logbook and switches to it (handy for a separate contest/SOTA log).{' '}
|
||||
<strong>Open existing</strong> points OpsLog at a file you already have.{' '}
|
||||
<strong>Save a copy</strong> clones the current database elsewhere and switches to it.{' '}
|
||||
Any database change takes effect on the next launch.
|
||||
</div>
|
||||
|
||||
{dbMsg && (
|
||||
@@ -2228,6 +2456,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backup settings, merged into this Database section. */}
|
||||
<div className="border-t border-border/60 mt-6 pt-5">
|
||||
{BackupPanel()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2249,6 +2482,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||
cat: CATPanel,
|
||||
rotator: RotatorPanel,
|
||||
winkeyer: WinkeyerPanel,
|
||||
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
||||
audio: () => <ComingSoon id="audio" icon={Server} />,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user