1286 lines
55 KiB
TypeScript
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>
|
|
);
|
|
}
|