rigs completed
This commit is contained in:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user