rigs completed

This commit is contained in:
2026-05-28 18:35:22 +02:00
parent d3c9982c66
commit e8cac569e3
26 changed files with 3834 additions and 391 deletions
+452 -67
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import {
ArrowDown, ArrowUp, Copy, Plus, Star, StarOff, Trash2,
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction,
@@ -15,11 +15,12 @@ import {
GetClusterAutoConnect, SetClusterAutoConnect,
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models';
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
@@ -33,6 +34,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { OperatingPanel } from '@/components/OperatingPanel';
type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm;
@@ -44,6 +46,55 @@ type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
type Profile = Omit<profileModels.Profile, 'convertValues'>;
// Catalog of all standard ADIF bands, in natural frequency order. The user
// picks a subset on the right; everything else in the UI (entry strip,
// band-slot grid, band-map switcher) iterates that subset.
const BAND_CATALOG = [
'2190m','630m','560m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m',
'8m','6m','5m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm',
'6mm','4mm','2.5mm','2mm','1mm',
];
// Catalog of common ADIF modes with sensible RST defaults. When the user
// picks one on the right, the RSTs are pre-filled but stay editable.
const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [
{ name: 'SSB', sent: '59', rcvd: '59' },
{ name: 'CW', sent: '599', rcvd: '599' },
{ name: 'AM', sent: '59', rcvd: '59' },
{ name: 'FM', sent: '59', rcvd: '59' },
{ name: 'DIGITALVOICE', sent: '59', rcvd: '59' },
{ name: 'FT8', sent: '-10', rcvd: '-10' },
{ name: 'FT4', sent: '-10', rcvd: '-10' },
{ name: 'JS8', sent: '-10', rcvd: '-10' },
{ name: 'MSK144', sent: '+00', rcvd: '+00' },
{ name: 'JT65', sent: '-15', rcvd: '-15' },
{ name: 'JT9', sent: '-15', rcvd: '-15' },
{ name: 'Q65', sent: '-15', rcvd: '-15' },
{ name: 'FST4', sent: '-15', rcvd: '-15' },
{ name: 'FST4W', sent: '-15', rcvd: '-15' },
{ name: 'WSPR', sent: '-20', rcvd: '-20' },
{ name: 'RTTY', sent: '599', rcvd: '599' },
{ name: 'PSK31', sent: '599', rcvd: '599' },
{ name: 'PSK63', sent: '599', rcvd: '599' },
{ name: 'PSK125', sent: '599', rcvd: '599' },
{ name: 'OLIVIA', sent: '599', rcvd: '599' },
{ name: 'CONTESTI', sent: '599', rcvd: '599' },
{ name: 'MFSK', sent: '599', rcvd: '599' },
{ name: 'THROB', sent: '599', rcvd: '599' },
{ name: 'HELL', sent: '599', rcvd: '599' },
{ name: 'PACKET', sent: '599', rcvd: '599' },
{ name: 'PACTOR', sent: '599', rcvd: '599' },
{ name: 'VARA', sent: '599', rcvd: '599' },
{ name: 'VARA HF', sent: '599', rcvd: '599' },
{ name: 'ARDOP', sent: '599', rcvd: '599' },
{ name: 'ATV', sent: '59', rcvd: '59' },
{ name: 'SSTV', sent: '59', rcvd: '59' },
{ name: 'C4FM', sent: '59', rcvd: '59' },
{ name: 'DSTAR', sent: '59', rcvd: '59' },
{ name: 'DMR', sent: '59', rcvd: '59' },
{ name: 'FUSION', sent: '59', rcvd: '59' },
];
const emptyProfile = (): Profile => ({
id: 0,
name: '',
@@ -72,6 +123,7 @@ interface Props {
type SectionId =
| 'station'
| 'profiles'
| 'operating'
| 'lookup'
| 'lists-bands'
| 'lists-modes'
@@ -92,6 +144,7 @@ const TREE: TreeNode[] = [
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
{ kind: 'item', label: 'Station Information', id: 'station' },
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
{ kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' },
],
},
{
@@ -102,7 +155,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
]},
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'Backup / Export', id: 'backup', disabled: true },
{ kind: 'item', label: 'Database backup', id: 'backup' },
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
],
},
@@ -120,11 +173,12 @@ const TREE: TreeNode[] = [
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
station: 'Station Information',
profiles: 'Profiles',
operating: 'Operating conditions',
lookup: 'Callsign Lookup',
'lists-bands': 'Bands',
'lists-modes': 'Modes & default RST',
cluster: 'DX Cluster',
backup: 'Backup / Export',
backup: 'Database backup',
awards: 'Awards',
cat: 'CAT interface',
rotator: 'Rotator',
@@ -248,7 +302,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const updateActive = (patch: Partial<Profile>) =>
setActiveProfile((p) => (p ? { ...p, ...patch } : p));
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [] });
const [bandsText, setBandsText] = useState('');
// Custom band drafts (catalog covers ADIF spec but the user may have
// exotic or experimental bands not listed).
const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
@@ -259,6 +316,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
enabled: false, folder: '', rotation: 5, zip: false,
last_backup_at: '', default_folder: '',
} as any);
const [backupRunning, setBackupRunning] = useState(false);
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
@@ -281,14 +345,14 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
// click Connect/Disconnect inside the modal and see the pills change
// without saving + reopening.
useEffect(() => {
EventsOn('cluster:state', async (st: any) => {
const unsub = EventsOn('cluster:state', async (st: any) => {
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
try {
const list = await ListClusterServers();
setClusterServers((list ?? []) as ClusterServer[]);
} catch {}
});
return () => { EventsOff('cluster:state'); };
return () => { unsub?.(); };
}, []);
const [profiles, setProfiles] = useState<Profile[]>([]);
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
@@ -325,18 +389,18 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
useEffect(() => {
(async () => {
try {
const [l, ls, c, ap, r] = await Promise.all([
const [l, ls, c, ap, r, b] = await Promise.all([
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
GetRotatorSettings(),
GetRotatorSettings(), GetBackupSettings(),
]);
setLookup(l);
setActiveProfile(ap as Profile);
setLists(ls);
await reloadProfiles();
await reloadClusterServers();
setBandsText((ls.bands ?? []).join('\n'));
setCatCfg(c);
setRotator(r);
setBackupCfg(b as any);
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
@@ -345,12 +409,59 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
})();
}, []);
// ── Band selection helpers (dual-list shuttle) ──────────────────────────
function addBand(tag: string) {
const b = tag.trim().toLowerCase();
if (!b) return;
setLists((l) => {
if ((l.bands ?? []).includes(b)) return l;
return { ...l, bands: [...(l.bands ?? []), b] };
});
}
function removeBand(i: number) {
setLists((l) => {
const next = [...(l.bands ?? [])];
next.splice(i, 1);
return { ...l, bands: next };
});
}
function moveBand(i: number, dir: -1 | 1) {
setLists((l) => {
const next = [...(l.bands ?? [])];
const j = i + dir;
if (j < 0 || j >= next.length) return l;
[next[i], next[j]] = [next[j], next[i]];
return { ...l, bands: next };
});
}
// ── Mode helpers ────────────────────────────────────────────────────────
function addMode() {
setLists((l) => ({
...l,
modes: [...(l.modes ?? []), { name: '', default_rst_sent: '59', default_rst_rcvd: '59' }],
}));
}
function addModeFromCatalog(m: { name: string; sent: string; rcvd: string }) {
setLists((l) => {
if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === m.name)) return l;
return {
...l,
modes: [...(l.modes ?? []), { name: m.name, default_rst_sent: m.sent, default_rst_rcvd: m.rcvd }],
};
});
}
function addCustomMode(name: string) {
const n = name.trim().toUpperCase();
if (!n) return;
setLists((l) => {
if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === n)) return l;
return {
...l,
modes: [...(l.modes ?? []), { name: n, default_rst_sent: '59', default_rst_rcvd: '59' }],
};
});
}
function removeMode(i: number) {
setLists((l) => {
const next = [...(l.modes ?? [])];
@@ -378,11 +489,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
async function save() {
setSaving(true); setErr(''); setMsg('');
try {
// Bands: dedup, lowercase, trim.
// Bands: dedup, lowercase, trim. Order = user's drag order.
const seen = new Set<string>();
const bands: string[] = [];
for (const line of bandsText.split('\n')) {
const b = line.trim().toLowerCase();
for (const raw of lists.bands ?? []) {
const b = (raw ?? '').trim().toLowerCase();
if (b && !seen.has(b)) { seen.add(b); bands.push(b); }
}
const modes = (lists.modes ?? [])
@@ -407,6 +518,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
await SaveRotatorSettings(rotator as any);
await SaveBackupSettings(backupCfg as any);
await SetClusterAutoConnect(clusterAutoConnect);
setMsg('Settings saved.');
@@ -506,23 +618,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Label>POTA ref</Label>
<Input className="font-mono uppercase" value={p.my_pota_ref ?? ''} onChange={(e) => updateActive({ my_pota_ref: e.target.value })} placeholder="FF-1234" />
</div>
<div className="space-y-1">
<Label>Rig</Label>
<Input value={p.my_rig ?? ''} onChange={(e) => updateActive({ my_rig: e.target.value })} placeholder="Flex 8600" />
</div>
<div className="space-y-1">
<Label>Antenna</Label>
<Input value={p.my_antenna ?? ''} onChange={(e) => updateActive({ my_antenna: e.target.value })} placeholder="Ultrabeam UB40" />
</div>
<div className="space-y-1">
<Label>TX power (W)</Label>
<Input
type="number"
value={p.tx_pwr ?? ''}
onChange={(e) => updateActive({ tx_pwr: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) })}
placeholder="100"
/>
</div>
</div>
</>
);
@@ -796,57 +891,212 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
}
function BandsPanel() {
const selected = lists.bands ?? [];
const selectedSet = new Set(selected.map((b) => (b ?? '').toLowerCase()));
const available = BAND_CATALOG.filter((b) => !selectedSet.has(b.toLowerCase()));
return (
<>
<SectionHeader title="Bands" hint="One ADIF band per line (e.g. 20m, 2m, 70cm). Order = display order in the entry form and the band-slot grid." />
<Textarea
className="font-mono min-h-[260px] max-w-md"
value={bandsText}
onChange={(e) => setBandsText(e.target.value)}
<SectionHeader
title="Bands"
hint="Pick the bands you actually use. The entry strip, the band-slot grid and the band-map switcher only show what's on the right. Order on the right = display order."
/>
<div className="grid grid-cols-[1fr_auto_1fr] gap-3 max-w-3xl">
{/* Left: available catalog */}
<div className="rounded-md border border-border overflow-hidden">
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
Available
</div>
<div className="max-h-[320px] overflow-y-auto divide-y divide-border">
{available.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground italic">All catalog bands selected.</div>
) : (
available.map((b) => (
<button
key={b}
type="button"
onDoubleClick={() => addBand(b)}
onClick={() => addBand(b)}
className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors"
>
{b}
</button>
))
)}
</div>
<div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
<Input
value={bandDraft}
onChange={(e) => setBandDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
addBand(bandDraft);
setBandDraft('');
}
}}
placeholder="Custom band (e.g. 4m)"
className="font-mono h-7 text-xs"
/>
<Button
size="sm"
variant="outline"
className="h-7"
onClick={() => { addBand(bandDraft); setBandDraft(''); }}
disabled={!bandDraft.trim()}
>
<Plus className="size-3" />
</Button>
</div>
</div>
{/* Center: shuttle hint */}
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<ArrowRight className="size-4" />
<ArrowLeft className="size-4" />
</div>
{/* Right: selected */}
<div className="rounded-md border border-border overflow-hidden">
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground flex items-center justify-between">
<span>Selected ({selected.length})</span>
</div>
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
{selected.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground italic">
No band selected — pick from the left.
</div>
) : (
selected.map((b, i) => (
<div key={`${b}-${i}`} className="grid grid-cols-[auto_1fr_auto] items-center gap-1 px-2 py-1">
<div className="flex gap-0.5">
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, -1)} disabled={i === 0}>
<ArrowUp className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, 1)} disabled={i === selected.length - 1}>
<ArrowDown className="size-3" />
</Button>
</div>
<span className="font-mono text-sm">{b}</span>
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeBand(i)}>
<Trash2 className="size-3" />
</Button>
</div>
))
)}
</div>
</div>
</div>
</>
);
}
function ModesPanel() {
const selected = lists.modes ?? [];
const selectedSet = new Set(selected.map((m) => (m.name ?? '').toUpperCase()));
const available = MODE_CATALOG.filter((m) => !selectedSet.has(m.name));
return (
<>
<SectionHeader
title="Modes & default RST"
hint="When you pick a mode in the entry form, RST sent/rcvd auto-fill with these defaults (unless you've typed something)."
hint="Pick the modes you actually use on the right. Anywhere the UI shows a mode picker, it iterates the right column. When you select a mode in the entry form, RST sent/rcvd auto-fill with the defaults below (unless you've typed something)."
/>
<div className="rounded-md border border-border overflow-hidden max-w-2xl">
<div className="grid grid-cols-[auto_1fr_1fr_1fr_auto] gap-2 px-3 py-2 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
<span className="w-12">Order</span>
<span>Mode (ADIF)</span>
<span>RST sent</span>
<span>RST rcvd</span>
<span className="w-8"></span>
<div className="grid grid-cols-[1fr_auto_1.5fr] gap-3 max-w-4xl">
{/* Left: available catalog */}
<div className="rounded-md border border-border overflow-hidden">
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
Available
</div>
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
{available.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground italic">All catalog modes selected.</div>
) : (
available.map((m) => (
<button
key={m.name}
type="button"
onDoubleClick={() => addModeFromCatalog(m)}
onClick={() => addModeFromCatalog(m)}
className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors flex items-center justify-between gap-2"
title={`Default RST: ${m.sent} / ${m.rcvd}`}
>
<span>{m.name}</span>
<span className="text-[10px] text-muted-foreground">{m.sent}/{m.rcvd}</span>
</button>
))
)}
</div>
<div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
<Input
value={modeDraft}
onChange={(e) => setModeDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
addCustomMode(modeDraft);
setModeDraft('');
}
}}
placeholder="Custom mode"
className="font-mono uppercase h-7 text-xs"
/>
<Button
size="sm"
variant="outline"
className="h-7"
onClick={() => { addCustomMode(modeDraft); setModeDraft(''); }}
disabled={!modeDraft.trim()}
>
<Plus className="size-3" />
</Button>
</div>
</div>
<div className="divide-y divide-border">
{(lists.modes ?? []).map((m, i) => (
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_auto] gap-2 px-3 py-1.5 items-center">
<div className="flex gap-0.5 w-12">
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, -1)} disabled={i === 0}>
<ArrowUp className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === (lists.modes?.length ?? 0) - 1}>
<ArrowDown className="size-3" />
</Button>
{/* Center: shuttle hint */}
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<ArrowRight className="size-4" />
<ArrowLeft className="size-4" />
</div>
{/* Right: selected with editable RST */}
<div className="rounded-md border border-border overflow-hidden">
<div className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
<span className="w-12">Order</span>
<span>Mode</span>
<span>RST snt</span>
<span>RST rcv</span>
<span className="w-6"></span>
</div>
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
{selected.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground italic">
No mode selected — pick from the left.
</div>
<Input className="font-mono uppercase h-7" value={m.name} onChange={(e) => updateMode(i, { name: e.target.value })} placeholder="SSB" />
<Input className="font-mono h-7" value={m.default_rst_sent ?? ''} onChange={(e) => updateMode(i, { default_rst_sent: e.target.value })} placeholder="59" />
<Input className="font-mono h-7" value={m.default_rst_rcvd ?? ''} onChange={(e) => updateMode(i, { default_rst_rcvd: e.target.value })} placeholder="59" />
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeMode(i)}>
<Trash2 className="size-3" />
</Button>
</div>
))}
) : (
selected.map((m, i) => (
<div key={i} className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1 items-center">
<div className="flex gap-0.5 w-12">
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, -1)} disabled={i === 0}>
<ArrowUp className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === selected.length - 1}>
<ArrowDown className="size-3" />
</Button>
</div>
<Input className="font-mono uppercase h-7" value={m.name} onChange={(e) => updateMode(i, { name: e.target.value })} placeholder="SSB" />
<Input className="font-mono h-7" value={m.default_rst_sent ?? ''} onChange={(e) => updateMode(i, { default_rst_sent: e.target.value })} placeholder="59" />
<Input className="font-mono h-7" value={m.default_rst_rcvd ?? ''} onChange={(e) => updateMode(i, { default_rst_rcvd: e.target.value })} placeholder="59" />
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeMode(i)}>
<Trash2 className="size-3" />
</Button>
</div>
))
)}
</div>
<div className="px-3 py-1.5 border-t border-border bg-muted/40">
<Button variant="ghost" size="sm" onClick={addMode} className="h-6 text-xs">
<Plus className="size-3" /> Add blank row
</Button>
</div>
</div>
</div>
<Button variant="outline" size="sm" onClick={addMode} className="mt-3">
<Plus className="size-3.5" /> Add mode
</Button>
</>
);
}
@@ -1152,15 +1402,150 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
);
}
function OperatingPanelWrapper() {
return (
<>
<SectionHeader
title="Operating conditions"
hint="Define your rigs and the antennas you use on each band. The entry strip will auto-fill MY_RIG and MY_ANTENNA based on the default antenna for the band you're operating on."
/>
<OperatingPanel
bands={lists.bands ?? []}
onError={(m) => setErr(m)}
/>
</>
);
}
function BackupPanel() {
const fmtLast = (iso: string) => {
if (!iso) return 'never';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())} UTC`;
};
const effectiveFolder = (backupCfg.folder || backupCfg.default_folder || '').replace(/\\/g, '/');
async function backupNow() {
setBackupRunning(true); setBackupResult(null);
try {
// Save current draft first so the backup runs with the values
// the user just typed (folder, rotation, zip) — otherwise the
// backend would use stale persisted config.
await SaveBackupSettings(backupCfg as any);
const path = await RunBackupNow();
setBackupResult({ ok: true, msg: 'Backup written to ' + path });
const refreshed = await GetBackupSettings();
setBackupCfg(refreshed as any);
} catch (e: any) {
setBackupResult({ ok: false, msg: String(e?.message ?? e) });
} finally { setBackupRunning(false); }
}
return (
<>
<SectionHeader
title="Database backup"
hint="HamLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!backupCfg.enabled}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, enabled: !!c }))}
/>
<span>Automatic backup when closing HamLog (max once per day)</span>
</label>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Backup folder</Label>
<div className="flex gap-2">
<Input
className="font-mono text-xs flex-1"
placeholder={backupCfg.default_folder || 'leave empty for default'}
value={backupCfg.folder ?? ''}
onChange={(e) => setBackupCfg((b) => ({ ...b, folder: e.target.value }))}
/>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
const p = await PickBackupFolder();
if (p) setBackupCfg((b) => ({ ...b, folder: p }));
} catch (e: any) {
setBackupResult({ ok: false, msg: String(e?.message ?? e) });
}
}}
>
Browse…
</Button>
</div>
<div className="text-[10px] text-muted-foreground">
{backupCfg.folder
? <>Effective folder: <span className="font-mono">{effectiveFolder}</span></>
: <>If empty, HamLog uses the default: <span className="font-mono">{(backupCfg.default_folder ?? '').replace(/\\/g, '/')}</span></>
}
</div>
</div>
<div className="flex items-end gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Rotation (copies to keep)</Label>
<Input
type="number"
min={1}
max={365}
className="w-24 font-mono text-xs"
value={backupCfg.rotation || 5}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n) && n > 0) setBackupCfg((b) => ({ ...b, rotation: Math.floor(n) }));
}}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
<Checkbox
checked={!!backupCfg.zip}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, zip: !!c }))}
/>
<span>ZIP backup (smaller file)</span>
</label>
</div>
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
<Button size="sm" onClick={backupNow} disabled={backupRunning}>
{backupRunning ? 'Backing up…' : 'Backup now'}
</Button>
<span className="text-xs text-muted-foreground">
Last backup: <strong className="text-foreground">{fmtLast(backupCfg.last_backup_at)}</strong>
</span>
</div>
{backupResult && (
<div className={cn(
'text-xs px-3 py-2 rounded-md border',
backupResult.ok
? 'bg-emerald-50 border-emerald-300 text-emerald-800'
: 'bg-rose-50 border-rose-300 text-rose-800',
)}>
{backupResult.msg}
</div>
)}
</div>
</>
);
}
// Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = {
station: StationPanel,
profiles: ProfilesPanel,
operating: OperatingPanelWrapper,
lookup: LookupPanel,
'lists-bands': BandsPanel,
'lists-modes': ModesPanel,
cluster: ClusterPanel,
backup: () => <ComingSoon id="backup" icon={Database} />,
backup: BackupPanel,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
rotator: RotatorPanel,
@@ -1170,7 +1555,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-[960px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader>
<DialogTitle>Preferences</DialogTitle>
<DialogDescription className="sr-only">Configure HamLog modules — station, lookup, hardware…</DialogDescription>
@@ -1179,7 +1564,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
{loading ? (
<div className="p-6 text-muted-foreground">Loading…</div>
) : (
<div className="grid grid-cols-[260px_1fr] min-h-0 overflow-hidden">
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
{/* Left sidebar tree */}
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
<Tree selected={selected} onSelect={setSelected} />