Files
OpsLog/frontend/src/components/SettingsModal.tsx
T
2026-05-28 11:09:07 +02:00

1286 lines
55 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import {
ArrowDown, ArrowUp, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction,
} from 'lucide-react';
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect,
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
} 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 {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm;
type ListsSettings = ListsSettingsForm;
type ModePreset = ModePresetForm;
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
type RotatorSettings = Omit<mainModels.RotatorSettings, 'convertValues'>;
type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
type Profile = Omit<profileModels.Profile, 'convertValues'>;
const emptyProfile = (): Profile => ({
id: 0,
name: '',
callsign: '', operator: '',
my_grid: '', my_country: '',
my_state: '', my_cnty: '',
my_street: '', my_city: '', my_postal_code: '',
my_sota_ref: '', my_pota_ref: '',
my_rig: '', my_antenna: '',
tx_pwr: undefined,
is_active: false,
sort_order: 0,
created_at: '' as any,
updated_at: '' as any,
});
interface Props {
initialSection?: string;
onClose: () => void;
onSaved: () => void;
}
/* ====== Tree definition ======
Section IDs are stable strings — adding new ones means adding a panel below.
`disabled: true` greys them out and shows the "coming soon" placeholder. */
type SectionId =
| 'station'
| 'profiles'
| 'lookup'
| 'lists-bands'
| 'lists-modes'
| 'cluster'
| 'backup'
| 'awards'
| 'cat'
| 'rotator'
| 'antenna'
| 'audio';
type TreeNode =
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
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: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [
{ kind: 'item', label: 'Callsign Lookup', id: 'lookup' },
{ kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
{ 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: 'Awards', id: 'awards', disabled: true },
],
},
{
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: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
],
},
];
// Map section id → friendly name (used in breadcrumb / placeholders).
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
station: 'Station Information',
profiles: 'Profiles',
lookup: 'Callsign Lookup',
'lists-bands': 'Bands',
'lists-modes': 'Modes & default RST',
cluster: 'DX Cluster',
backup: 'Backup / Export',
awards: 'Awards',
cat: 'CAT interface',
rotator: 'Rotator',
antenna: 'Antenna',
audio: 'Audio devices',
};
// ===== Tree component =====
interface TreeProps {
selected: SectionId;
onSelect: (id: SectionId) => void;
}
function Tree({ selected, onSelect }: TreeProps) {
return (
<nav className="text-sm">
{TREE.map((node, i) => (
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
))}
</nav>
);
}
function TreeNodeView({
node, depth, selected, onSelect,
}: { node: TreeNode; depth: number; selected: SectionId; onSelect: (id: SectionId) => void }) {
if (node.kind === 'item') {
const isActive = selected === node.id;
return (
<button
onClick={() => { if (!node.disabled) onSelect(node.id); }}
disabled={node.disabled}
className={cn(
'w-full text-left px-2 py-1.5 rounded-md text-[12.5px] transition-colors flex items-center',
isActive ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60',
node.disabled && 'opacity-50 cursor-not-allowed italic',
)}
style={{ paddingLeft: 8 + depth * 14 }}
>
<span className="truncate">{node.label}</span>
{node.disabled && (
<Construction className="ml-auto size-3 shrink-0 opacity-60" />
)}
</button>
);
}
// group
const [open, setOpen] = useState(node.defaultOpen ?? false);
const Icon = node.icon;
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className="w-full text-left px-2 py-1.5 rounded-md text-[12px] uppercase tracking-wider text-muted-foreground hover:text-foreground flex items-center gap-1.5 font-semibold"
style={{ paddingLeft: 8 + depth * 14 }}
>
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
{Icon && <Icon className="size-3.5 opacity-70" />}
<span>{node.label}</span>
</button>
{open && (
<div>
{node.children.map((c, i) => (
<TreeNodeView key={i} node={c} depth={depth + 1} selected={selected} onSelect={onSelect} />
))}
</div>
)}
</div>
);
}
// ===== Section content panels =====
function SectionHeader({ title, hint }: { title: string; hint?: string }) {
return (
<header className="mb-4">
<h3 className="text-base font-semibold text-foreground">{title}</h3>
{hint && <p className="text-xs text-muted-foreground mt-0.5">{hint}</p>}
</header>
);
}
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction;
return (
<div className="flex flex-col items-center justify-center text-center text-muted-foreground gap-2 py-12">
<IconCmp className="size-10 opacity-40" />
<div className="text-base font-semibold text-foreground/70">{label}</div>
<div className="text-sm">Module coming soon.</div>
</div>
);
}
export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [clearing, setClearing] = useState(false);
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
const [lookup, setLookup] = useState<LookupSettings>({
qrz_user: '', qrz_password: '',
hamqth_user: '', hamqth_password: '',
primary: '', failsafe: '',
download_images: false,
cache_ttl_days: 30,
});
// Per-provider Test state — keeps the success/error feedback adjacent
// to the button. Cleared on the next test run for that provider.
type TestResult = { ok: boolean; msg: string };
const [lookupTest, setLookupTest] = useState<Record<string, TestResult | undefined>>({});
const [lookupTesting, setLookupTesting] = useState<Record<string, boolean>>({});
// The Station Information panel now edits the full active profile
// (not a flat 6-field StationSettings). Profile selection happens in
// the Profiles panel; any edit here saves back to whichever profile
// is currently active.
const [activeProfile, setActiveProfile] = useState<Profile | null>(null);
const updateActive = (patch: Partial<Profile>) =>
setActiveProfile((p) => (p ? { ...p, ...patch } : p));
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [] });
const [bandsText, setBandsText] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
});
const [rotator, setRotator] = useState<RotatorSettings>({
enabled: false, host: '127.0.0.1', port: 12000, has_elevation: false,
});
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
const [editingServer, setEditingServer] = useState<ClusterServer | null>(null);
async function reloadClusterServers() {
try {
const [list, ac, st] = await Promise.all([
ListClusterServers(),
GetClusterAutoConnect(),
GetClusterStatus(),
]);
setClusterServers((list ?? []) as ClusterServer[]);
setClusterAutoConnectState(ac);
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Live cluster status updates while Preferences is open — the user can
// click Connect/Disconnect inside the modal and see the pills change
// without saving + reopening.
useEffect(() => {
EventsOn('cluster:state', async (st: any) => {
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
try {
const list = await ListClusterServers();
setClusterServers((list ?? []) as ClusterServer[]);
} catch {}
});
return () => { EventsOff('cluster:state'); };
}, []);
const [profiles, setProfiles] = useState<Profile[]>([]);
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
// the panel as a plain function, not as a JSX element, so any useState
// inside the panel function would violate the Rules of Hooks.
const [profileSelectedId, setProfileSelectedId] = useState<number>(0);
const [profileNameDraft, setProfileNameDraft] = useState<string>('');
async function reloadProfiles() {
try {
const list = await ListProfiles();
setProfiles(list);
// Refresh the active-profile editor in case activation changed.
const ap = await GetActiveProfile();
setActiveProfile(ap as Profile);
} catch (e: any) {
setErr(String(e?.message ?? e));
}
}
// Keep the ProfilesPanel selector in sync with the loaded list. If the
// currently-selected profile is gone (post-delete) or none is selected
// yet, default to the active one.
useEffect(() => {
if (!profiles.length) return;
const stillThere = profiles.some((p) => (p.id as number) === profileSelectedId);
if (!stillThere) {
const next = profiles.find((p) => p.is_active) ?? profiles[0];
setProfileSelectedId(next.id as number);
setProfileNameDraft(next.name);
}
}, [profiles, profileSelectedId]);
useEffect(() => {
(async () => {
try {
const [l, ls, c, ap, r] = await Promise.all([
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
GetRotatorSettings(),
]);
setLookup(l);
setActiveProfile(ap as Profile);
setLists(ls);
await reloadProfiles();
await reloadClusterServers();
setBandsText((ls.bands ?? []).join('\n'));
setCatCfg(c);
setRotator(r);
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setLoading(false);
}
})();
}, []);
function addMode() {
setLists((l) => ({
...l,
modes: [...(l.modes ?? []), { name: '', default_rst_sent: '59', default_rst_rcvd: '59' }],
}));
}
function removeMode(i: number) {
setLists((l) => {
const next = [...(l.modes ?? [])];
next.splice(i, 1);
return { ...l, modes: next };
});
}
function moveMode(i: number, dir: -1 | 1) {
setLists((l) => {
const next = [...(l.modes ?? [])];
const j = i + dir;
if (j < 0 || j >= next.length) return l;
[next[i], next[j]] = [next[j], next[i]];
return { ...l, modes: next };
});
}
function updateMode(i: number, patch: Partial<ModePreset>) {
setLists((l) => {
const next = [...(l.modes ?? [])];
next[i] = { ...next[i], ...patch } as ModePreset;
return { ...l, modes: next };
});
}
async function save() {
setSaving(true); setErr(''); setMsg('');
try {
// Bands: dedup, lowercase, trim.
const seen = new Set<string>();
const bands: string[] = [];
for (const line of bandsText.split('\n')) {
const b = line.trim().toLowerCase();
if (b && !seen.has(b)) { seen.add(b); bands.push(b); }
}
const modes = (lists.modes ?? [])
.map((m) => ({
name: (m.name ?? '').trim().toUpperCase(),
default_rst_sent: (m.default_rst_sent ?? '').trim(),
default_rst_rcvd: (m.default_rst_rcvd ?? '').trim(),
}))
.filter((m) => m.name !== '');
await SaveListsSettings({ bands, modes } as any);
if (activeProfile) {
await SaveProfile({
...activeProfile,
callsign: (activeProfile.callsign ?? '').trim().toUpperCase(),
operator: (activeProfile.operator ?? '').trim().toUpperCase(),
my_grid: (activeProfile.my_grid ?? '').trim().toUpperCase(),
my_sota_ref: (activeProfile.my_sota_ref ?? '').trim().toUpperCase(),
my_pota_ref: (activeProfile.my_pota_ref ?? '').trim().toUpperCase(),
} as any);
}
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
await SaveRotatorSettings(rotator as any);
await SetClusterAutoConnect(clusterAutoConnect);
setMsg('Settings saved.');
onSaved();
setTimeout(onClose, 500);
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setSaving(false);
}
}
async function clearCache() {
setClearing(true); setErr(''); setMsg('');
try {
await ClearLookupCache();
setMsg('Cache cleared.');
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setClearing(false);
}
}
async function testProvider(provider: 'qrz' | 'hamqth') {
setLookupTesting((s) => ({ ...s, [provider]: true }));
setLookupTest((s) => ({ ...s, [provider]: undefined }));
const user = provider === 'qrz' ? lookup.qrz_user : lookup.hamqth_user;
const pwd = provider === 'qrz' ? lookup.qrz_password : lookup.hamqth_password;
try {
const r = await TestLookupProvider(provider, '', user ?? '', pwd ?? '');
setLookupTest((s) => ({ ...s, [provider]: { ok: true, msg: `OK — ${r.callsign} (${r.name || r.country || 'no name'})` } }));
} catch (e: any) {
setLookupTest((s) => ({ ...s, [provider]: { ok: false, msg: String(e?.message ?? e) } }));
} finally {
setLookupTesting((s) => ({ ...s, [provider]: false }));
}
}
const breadcrumb = useMemo(() => SECTION_LABELS[selected] ?? selected, [selected]);
// === Section content renderers ===
function StationPanel() {
if (!activeProfile) {
return <div className="text-muted-foreground text-sm">Loading profile</div>;
}
const p = activeProfile;
return (
<>
<SectionHeader
title="Station Information"
hint={`Editing the active profile: ${p.name}. Switch profiles in the Profiles section to edit a different one.`}
/>
<div className="grid grid-cols-2 gap-3 max-w-2xl">
<div className="space-y-1">
<Label>Station callsign</Label>
<Input className="font-mono uppercase" value={p.callsign ?? ''} onChange={(e) => updateActive({ callsign: e.target.value })} placeholder="F4XYZ" />
</div>
<div className="space-y-1">
<Label>Operator</Label>
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
</div>
<div className="space-y-1">
<Label>My grid</Label>
<Input className="font-mono uppercase" value={p.my_grid ?? ''} onChange={(e) => updateActive({ my_grid: e.target.value })} placeholder="JN18BU" />
</div>
<div className="space-y-1">
<Label>My country</Label>
<Input value={p.my_country ?? ''} onChange={(e) => updateActive({ my_country: e.target.value })} placeholder="France" />
</div>
<div className="space-y-1">
<Label>State / pref</Label>
<Input value={p.my_state ?? ''} onChange={(e) => updateActive({ my_state: e.target.value })} />
</div>
<div className="space-y-1">
<Label>County</Label>
<Input value={p.my_cnty ?? ''} onChange={(e) => updateActive({ my_cnty: e.target.value })} />
</div>
<div className="space-y-1 col-span-2">
<Label>Street address</Label>
<Input value={p.my_street ?? ''} onChange={(e) => updateActive({ my_street: e.target.value })} />
</div>
<div className="space-y-1">
<Label>Postal code</Label>
<Input value={p.my_postal_code ?? ''} onChange={(e) => updateActive({ my_postal_code: e.target.value })} />
</div>
<div className="space-y-1">
<Label>City</Label>
<Input value={p.my_city ?? ''} onChange={(e) => updateActive({ my_city: e.target.value })} />
</div>
<div className="space-y-1">
<Label>SOTA ref</Label>
<Input className="font-mono uppercase" value={p.my_sota_ref ?? ''} onChange={(e) => updateActive({ my_sota_ref: e.target.value })} placeholder="F/AB-001" />
</div>
<div className="space-y-1">
<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>
</>
);
}
// Profile actions — kept at the SettingsModal level so the ProfilesPanel
// renderer can stay hooks-free (the PANELS map calls it as a plain
// function, not as a JSX component).
const activeProfileObj = profiles.find((p) => p.is_active) ?? profiles[0];
const currentProfile = profiles.find((p) => (p.id as number) === profileSelectedId);
async function profileActivate() {
if (!currentProfile) return;
try { await ActivateProfile(currentProfile.id as number); await reloadProfiles(); onSaved(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function profileRemove() {
if (!currentProfile) return;
if (!confirm(`Delete profile "${currentProfile.name}"? All its settings will be lost.`)) return;
try { await DeleteProfile(currentProfile.id as number); await reloadProfiles(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function profileDuplicate() {
if (!currentProfile) return;
const name = prompt(`Name for the new profile (copy of "${currentProfile.name}"):`, `${currentProfile.name} Copy`);
if (!name?.trim()) return;
try {
const dup = await DuplicateProfile(currentProfile.id as number, name.trim());
await reloadProfiles();
setProfileSelectedId(dup.id as number);
setProfileNameDraft(dup.name);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function profileCreateBlank() {
const name = prompt('Name for the new profile:', 'New profile');
if (!name?.trim()) return;
try {
const blank = emptyProfile();
blank.name = name.trim();
const saved = await SaveProfile(blank as any);
await reloadProfiles();
setProfileSelectedId(saved.id as number);
setProfileNameDraft(saved.name);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function profileRenameCurrent() {
if (!currentProfile || profileNameDraft.trim() === currentProfile.name) return;
try {
await SaveProfile({ ...currentProfile, name: profileNameDraft.trim() } as any);
await reloadProfiles();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
function ProfilesPanel() {
const current = currentProfile;
const active = activeProfileObj;
return (
<>
<SectionHeader
title="Profiles"
hint="Switch between operating identities (home / portable / SOTA / contest). Pick a profile here, then edit its fields in the other sections (Station Information, etc.) — changes are saved against the selected profile."
/>
<div className="space-y-4">
<div className="grid grid-cols-[140px_1fr] items-center gap-x-3 gap-y-3">
<Label>Configuration ID</Label>
<Select
value={String(profileSelectedId)}
onValueChange={(v) => {
const id = parseInt(v, 10);
setProfileSelectedId(id);
setProfileNameDraft(profiles.find((p) => (p.id as number) === id)?.name ?? '');
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{profiles.map((p) => (
<SelectItem key={p.id as number} value={String(p.id)}>
{p.name}{p.callsign ? `${p.callsign}` : ''}{p.is_active ? ' (active)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Label>Description</Label>
<div className="flex items-center gap-2">
<Input
value={profileNameDraft}
onChange={(e) => setProfileNameDraft(e.target.value)}
onBlur={profileRenameCurrent}
placeholder="Profile name"
disabled={!current}
/>
{current?.is_active && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold tracking-wider bg-emerald-100 text-emerald-800 border border-emerald-300">
ACTIVE
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={profileCreateBlank} title="Create a new empty profile">
<Plus className="size-3.5 mr-1" /> New
</Button>
<Button variant="outline" size="sm" onClick={profileDuplicate} disabled={!current} title="Clone the selected profile (keeps all its fields)">
<Copy className="size-3.5 mr-1" /> Duplicate
</Button>
<Button
variant="outline" size="sm"
onClick={profileActivate}
disabled={!current || current.is_active}
title="Activate the selected profile — new QSOs will use its MY_* fields"
>
<Star className="size-3.5 mr-1" /> Set active
</Button>
<Button
variant="outline" size="sm"
onClick={profileRemove}
disabled={!current || profiles.length <= 1}
className="text-destructive hover:text-destructive ml-auto"
title={profiles.length <= 1 ? 'Cannot delete the last profile' : 'Delete the selected profile'}
>
<Trash2 className="size-3.5 mr-1" /> Delete
</Button>
</div>
{current && !current.is_active && (
<div className="text-xs text-muted-foreground bg-muted/30 border border-border rounded-md p-2.5">
You're viewing <strong>{current.name}</strong>. The active profile is <strong>{active?.name}</strong> — its values are stamped on new QSOs. Click <em>Set active</em> to switch.
</div>
)}
</div>
</>
);
}
function LookupPanel() {
// Per-row provider editor — kept inline because it's only used twice
// and needs closure access to the parent state.
const row = (
key: 'qrz' | 'hamqth', label: string, userField: 'qrz_user' | 'hamqth_user',
pwdField: 'qrz_password' | 'hamqth_password',
) => {
const test = lookupTest[key];
const testing = lookupTesting[key];
const hasCreds = !!(lookup[userField] && lookup[pwdField]);
return (
<tr className="border-t border-border align-middle">
<td className="px-3 py-2 font-semibold whitespace-nowrap">{label}</td>
<td className="px-2 py-2 text-center">
<input
type="radio"
name="lookup-primary"
checked={lookup.primary === key}
onChange={() => setLookup((s) => ({ ...s, primary: key, failsafe: s.failsafe === key ? '' : s.failsafe }))}
disabled={!hasCreds}
/>
</td>
<td className="px-2 py-2 text-center">
<input
type="radio"
name="lookup-failsafe"
checked={lookup.failsafe === key}
onChange={() => setLookup((s) => ({ ...s, failsafe: key, primary: s.primary === key ? '' : s.primary }))}
disabled={!hasCreds || lookup.primary === key}
/>
</td>
<td className="px-2 py-2">
<Input
className="h-8"
value={lookup[userField] ?? ''}
onChange={(e) => setLookup((s) => ({ ...s, [userField]: e.target.value }))}
placeholder="User"
autoComplete="off"
/>
</td>
<td className="px-2 py-2">
<Input
className="h-8"
type="password"
value={lookup[pwdField] ?? ''}
onChange={(e) => setLookup((s) => ({ ...s, [pwdField]: e.target.value }))}
placeholder="Password"
autoComplete="off"
/>
</td>
<td className="px-2 py-2 whitespace-nowrap">
<Button
variant="outline" size="sm"
onClick={() => testProvider(key)}
disabled={!hasCreds || testing}
title="Run a sample lookup against the active profile's callsign to verify credentials"
>
{testing ? 'Testing…' : 'Test'}
</Button>
</td>
<td className={cn('px-2 py-2 text-xs', test?.ok ? 'text-emerald-700' : 'text-destructive')}>
{test?.msg}
</td>
</tr>
);
};
return (
<>
<SectionHeader
title="Callsign Lookup"
hint="Pick a Primary provider and an optional Failsafe (queried only when Primary returns no data). Click Test to verify credentials without saving."
/>
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-3 py-2">Provider</th>
<th className="px-2 py-2 w-20">Primary</th>
<th className="px-2 py-2 w-20">Failsafe</th>
<th className="text-left px-2 py-2">User</th>
<th className="text-left px-2 py-2">Password</th>
<th className="px-2 py-2 w-20"></th>
<th className="text-left px-2 py-2">Result</th>
</tr>
</thead>
<tbody>
{row('qrz', 'QRZ.com', 'qrz_user', 'qrz_password')}
{row('hamqth', 'HamQTH', 'hamqth_user', 'hamqth_password')}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground mt-2">
Failsafe is consulted only when the Primary returns no match or errors. Set both to none (uncheck) during contests to skip the network entirely.
</p>
<div className="mt-6 pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2">Display</h3>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={lookup.download_images}
onCheckedChange={(c) => setLookup((s) => ({ ...s, download_images: !!c }))}
className="mt-0.5"
/>
<span>
Show QRZ profile pictures
<span className="block text-xs text-muted-foreground mt-0.5">
Display the photo from QRZ.com next to the worked-before matrix.
May noticeably slow lookups during busy contest days; turn off if you operate fast.
</span>
</span>
</label>
</div>
<div className="mt-6 pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2">Cache</h3>
<p className="text-xs text-muted-foreground mb-3">
Successful lookups are cached locally so the same callsign isn't fetched twice. TTL controls how long before a fresh query is made.
</p>
<div className="flex gap-3 items-end">
<div className="space-y-1 w-40">
<Label>TTL (days)</Label>
<Input
type="number" min={1} max={3650}
value={lookup.cache_ttl_days}
onChange={(e) => setLookup((s) => ({ ...s, cache_ttl_days: parseInt(e.target.value) || 30 }))}
/>
</div>
<Button variant="outline" onClick={clearCache} disabled={clearing}>
{clearing ? 'Clearing' : 'Clear cache now'}
</Button>
</div>
</div>
</>
);
}
function BandsPanel() {
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)}
/>
</>
);
}
function ModesPanel() {
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)."
/>
<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>
<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>
</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>
<Button variant="outline" size="sm" onClick={addMode} className="mt-3">
<Plus className="size-3.5" /> Add mode
</Button>
</>
);
}
function CATPanel() {
return (
<>
<SectionHeader
title="CAT interface (OmniRig)"
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig — HamLog just talks to it."
/>
<div className="space-y-4 max-w-lg">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
Enable CAT
</label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Backend</Label>
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="omnirig">OmniRig (Windows COM)</SelectItem>
<SelectItem value="flex" disabled>Flex SmartSDR (coming soon)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>OmniRig rig slot</Label>
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">Rig 1</SelectItem>
<SelectItem value="2">Rig 2</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Poll interval (ms)</Label>
<Input
type="number" min={50} max={2000} step={50}
value={catCfg.poll_ms}
onChange={(e) => setCatCfg((s) => ({ ...s, poll_ms: parseInt(e.target.value) || 250 }))}
/>
</div>
<div className="space-y-1">
<Label>CAT delay (ms)</Label>
<Input
type="number" min={0} max={500} step={10}
value={catCfg.delay_ms}
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
/>
</div>
<div className="space-y-1 col-span-2">
<Label>Default digital mode (when rig reports DIG)</Label>
<Select
value={catCfg.digital_default || 'FT8'}
onValueChange={(v) => setCatCfg((s) => ({ ...s, digital_default: v }))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{['FT8','FT4','RTTY','PSK31','MFSK','JS8','JT65','JT9','OLIVIA','DIGITALVOICE','DATA'].map(m => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
Configure your rig (COM port, baud rate, model) in OmniRig's own settings GUI first.
HamLog will read whichever Rig slot you select here. Set <strong>CAT delay</strong>
{' '}above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu).
OmniRig only reports generic "DIG" for digital modes <strong>Default digital mode</strong>
{' '}is the specific mode HamLog will surface (and log).
</p>
</div>
</>
);
}
async function testRotator() {
setRotatorTesting(true);
setRotatorTest(null);
try {
await TestRotator(rotator as any);
setRotatorTest({ ok: true, msg: 'Packet sent — antenna should swing to 0° (north). If it didn\'t, check PstRotator host/port and that PstRotator\'s UDP listener is enabled.' });
} catch (e: any) {
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
setRotatorTesting(false);
}
}
function RotatorPanel() {
return (
<>
<SectionHeader
title="Rotator (PstRotator)"
hint="HamLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing."
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
Enable PstRotator control
</label>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host</Label>
<Input
value={rotator.host ?? ''}
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
placeholder="127.0.0.1"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>UDP port</Label>
<Input
type="number" min={1} max={65535}
value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || 12000 }))}
className="font-mono"
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
This rotator supports elevation (VHF / satellite)
</label>
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
{rotatorTesting ? 'Sending…' : 'Test (point to 0°)'}
</Button>
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
Stop
</Button>
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
Park
</Button>
</div>
{rotatorTest && (
<div className={cn(
'text-xs rounded-md p-2.5 border',
rotatorTest.ok
? 'bg-emerald-50 text-emerald-800 border-emerald-200'
: 'bg-destructive/10 text-destructive border-destructive/30',
)}>
{rotatorTest.msg}
</div>
)}
<p className="text-xs text-muted-foreground">
From the main entry strip, click the bearing pill to rotate to the short-path azimuth.
Shift+click for long-path, Ctrl+click to stop.
</p>
</div>
</>
);
}
function statusForServer(id: number): ClusterServerStatus | undefined {
return clusterStatuses.find((s) => (s.server_id as number) === id);
}
async function clusterToggleEnabled(srv: ClusterServer, on: boolean) {
try {
await SaveClusterServer({ ...srv, enabled: on } as any);
await reloadClusterServers();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function clusterDeleteServer(srv: ClusterServer) {
if (!confirm(`Delete cluster "${srv.name}"? Active session will be closed.`)) return;
try { await DeleteClusterServer(srv.id as number); await reloadClusterServers(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function clusterMove(srv: ClusterServer, dir: -1 | 1) {
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const idx = sorted.findIndex((s) => s.id === srv.id);
const j = idx + dir;
if (idx < 0 || j < 0 || j >= sorted.length) return;
const a = sorted[idx], b = sorted[j];
try {
await SaveClusterServer({ ...a, sort_order: b.sort_order } as any);
await SaveClusterServer({ ...b, sort_order: a.sort_order } as any);
await reloadClusterServers();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
function clusterAddNew() {
const next: ClusterServer = {
id: 0, name: '', host: '', port: 7300,
login_override: '', password: '', init_commands: '',
enabled: true, sort_order: clusterServers.length,
};
setEditingServer(next);
}
function ClusterPanel() {
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
return (
<>
<SectionHeader
title="DX Cluster"
hint="Connect to one or several DX cluster nodes (telnet). The first enabled server is the master — typed commands and init commands go through it."
/>
<div className="space-y-4">
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-3 py-2 w-10"></th>
<th className="text-left px-3 py-2">Name</th>
<th className="text-left px-3 py-2">Host:port</th>
<th className="text-left px-3 py-2 w-28">Status</th>
<th className="px-3 py-2 w-32">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((s, i) => {
const st = statusForServer(s.id as number);
const state = (st?.state ?? 'disconnected') as string;
const isMaster = i === sorted.findIndex((x) => x.enabled);
return (
<tr key={s.id as number} className="border-t border-border align-middle">
<td className="px-2 py-2 text-center">
<Checkbox
checked={s.enabled}
onCheckedChange={(c) => clusterToggleEnabled(s, !!c)}
/>
</td>
<td className="px-3 py-2 font-medium">
{s.name}
{isMaster && s.enabled && (
<span className="ml-2 inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider bg-amber-100 text-amber-800 border border-amber-300">MASTER</span>
)}
</td>
<td className="px-3 py-2 font-mono text-xs">{s.host}:{s.port}</td>
<td className="px-3 py-2">
<span className={cn(
'inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider border',
state === 'connected' ? 'bg-emerald-100 text-emerald-800 border-emerald-300' :
state === 'connecting' || state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' :
state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' :
'bg-muted text-muted-foreground border-border',
)}>
{state.toUpperCase()}
{st?.retries ? ` #${st.retries}` : ''}
</span>
</td>
<td className="px-2 py-2">
<div className="flex items-center gap-0.5 justify-end">
<Button variant="ghost" size="icon" className="size-6" disabled={i === 0} onClick={() => clusterMove(s, -1)} title="Move up"><ArrowUp className="size-3" /></Button>
<Button variant="ghost" size="icon" className="size-6" disabled={i === sorted.length - 1} onClick={() => clusterMove(s, 1)} title="Move down"><ArrowDown className="size-3" /></Button>
<Button variant="ghost" size="icon" className="size-6" onClick={() => setEditingServer(s)} title="Edit"><Cog className="size-3.5" /></Button>
<Button variant="ghost" size="icon" className="size-6 text-destructive hover:text-destructive" onClick={() => clusterDeleteServer(s)} title="Delete"><Trash2 className="size-3.5" /></Button>
</div>
</td>
</tr>
);
})}
{sorted.length === 0 && (
<tr><td colSpan={5} className="px-3 py-4 text-center text-muted-foreground text-xs">No cluster nodes saved yet.</td></tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={clusterAddNew}>
<Plus className="size-3.5 mr-1" /> Add cluster
</Button>
<Button variant="outline" size="sm" onClick={async () => { await ConnectAllClusters().catch(() => {}); await reloadClusterServers(); }}>
Connect all
</Button>
<Button variant="outline" size="sm" onClick={async () => { await DisconnectAllClusters().catch(() => {}); await reloadClusterServers(); }}>
Disconnect all
</Button>
<label className="flex items-center gap-2 text-sm cursor-pointer ml-auto">
<Checkbox checked={clusterAutoConnect} onCheckedChange={(c) => setClusterAutoConnectState(!!c)} />
Auto-connect all enabled on app start
</label>
</div>
<p className="text-xs text-muted-foreground">
Free public nodes: <span className="font-mono">dxc.k0xm.net:7300</span>,{' '}
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
<span className="font-mono">w8avi.net:7300</span>.
</p>
</div>
{editingServer && (
<ClusterServerEditor
value={editingServer}
onCancel={() => setEditingServer(null)}
onSave={async (srv) => {
try {
await SaveClusterServer(srv as any);
await reloadClusterServers();
setEditingServer(null);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}}
/>
)}
</>
);
}
// Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = {
station: StationPanel,
profiles: ProfilesPanel,
lookup: LookupPanel,
'lists-bands': BandsPanel,
'lists-modes': ModesPanel,
cluster: ClusterPanel,
backup: () => <ComingSoon id="backup" icon={Database} />,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
rotator: RotatorPanel,
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
audio: () => <ComingSoon id="audio" icon={Server} />,
};
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">
<DialogHeader>
<DialogTitle>Preferences</DialogTitle>
<DialogDescription className="sr-only">Configure HamLog modules station, lookup, hardware</DialogDescription>
</DialogHeader>
{loading ? (
<div className="p-6 text-muted-foreground">Loading</div>
) : (
<div className="grid grid-cols-[260px_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} />
</aside>
{/* Right content pane */}
<div className="overflow-y-auto p-6">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
{breadcrumb}
</div>
{PANELS[selected]?.()}
{err && (
<div className="mt-6 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2 max-w-2xl">
{err}
</div>
)}
{msg && (
<div className="mt-6 text-xs rounded-md px-3 py-2 max-w-2xl text-[color:var(--color-ok)] bg-[color:var(--color-ok)]/10 border border-[color:var(--color-ok)]/30">
{msg}
</div>
)}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={save} disabled={saving || loading}>{saving ? 'Saving…' : 'Save all'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ClusterServerEditor edits one row of cluster_servers. Init commands are
// free-form (one per line); the backend strips blanks and "//" comments.
interface ClusterEditorProps {
value: Omit<clusterModels.ServerConfig, 'convertValues'>;
onCancel: () => void;
onSave: (s: Omit<clusterModels.ServerConfig, 'convertValues'>) => void | Promise<void>;
}
function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
const [s, setS] = useState(value);
const update = (patch: Partial<typeof s>) => setS((cur) => ({ ...cur, ...patch }));
return (
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
<DialogContent className="max-w-[640px] px-6">
<DialogHeader className="px-2">
<DialogTitle>{s.id ? `Edit cluster · ${s.name || 'unnamed'}` : 'New cluster'}</DialogTitle>
<DialogDescription className="text-xs">
Telnet endpoint + optional login override and init commands. Init commands are sent one per line, 0.5s apart, right after login.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-2 px-2">
<div className="space-y-1 col-span-2">
<Label>Display name</Label>
<Input autoFocus value={s.name} onChange={(e) => update({ name: e.target.value })} placeholder="VE7CC, F4BPO home…" />
</div>
<div className="space-y-1">
<Label>Host</Label>
<Input className="font-mono" value={s.host} onChange={(e) => update({ host: e.target.value })} placeholder="dxc.k0xm.net" />
</div>
<div className="space-y-1">
<Label>Port</Label>
<Input type="number" min={1} max={65535} className="font-mono" value={s.port} onChange={(e) => update({ port: parseInt(e.target.value) || 7300 })} />
</div>
<div className="space-y-1">
<Label>Login callsign (optional)</Label>
<Input className="font-mono uppercase" value={s.login_override} onChange={(e) => update({ login_override: e.target.value })} placeholder="Active profile if empty" />
</div>
<div className="space-y-1">
<Label>Password (optional)</Label>
<Input type="password" value={s.password} onChange={(e) => update({ password: e.target.value })} autoComplete="off" />
</div>
<div className="space-y-1 col-span-2">
<Label>Init commands (one per line, // for comments)</Label>
<Textarea
className="font-mono text-xs min-h-[120px]"
value={s.init_commands}
onChange={(e) => update({ init_commands: e.target.value })}
placeholder={`// turn on DXCC info\nset/needsdxcc\nsh/dx 30`}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-2">
<Checkbox checked={s.enabled} onCheckedChange={(c) => update({ enabled: !!c })} />
Enabled (will be connected at startup if Auto-connect is on)
</label>
</div>
<DialogFooter className="px-2">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button
onClick={() => onSave({ ...s, name: s.name.trim(), host: s.host.trim(), login_override: s.login_override.trim().toUpperCase() })}
disabled={!s.name.trim() || !s.host.trim()}
>
{s.id ? 'Save changes' : 'Create cluster'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}