3030 lines
140 KiB
TypeScript
3030 lines
140 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||
ChevronDown, ChevronRight,
|
||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||
Compass, Wifi, Construction, UploadCloud, Loader2,
|
||
} from 'lucide-react';
|
||
import {
|
||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||
GetListsSettings, SaveListsSettings,
|
||
GetCATSettings, SaveCATSettings,
|
||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||
GetEmailSettings, SaveEmailSettings, TestEmail,
|
||
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
|
||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||
GetClusterAutoConnect, SetClusterAutoConnect,
|
||
ConnectClusterServer, DisconnectClusterServer,
|
||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
||
GetQSLDefaults, SaveQSLDefaults,
|
||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||
GetPOTAToken, SavePOTAToken, SyncPOTAHunterLog,
|
||
TestLoTWUpload, ListTQSLStationLocations,
|
||
ComputeStationInfo,
|
||
} 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 } 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';
|
||
import { OperatingPanel } from '@/components/OperatingPanel';
|
||
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
||
|
||
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'>;
|
||
|
||
// Catalog of all standard ADIF bands, in natural frequency order. The user
|
||
// picks a subset on the right; everything else in the UI (entry strip,
|
||
// band-slot grid, band-map switcher) iterates that subset.
|
||
const BAND_CATALOG = [
|
||
'2190m','630m','560m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m',
|
||
'8m','6m','5m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm',
|
||
'6mm','4mm','2.5mm','2mm','1mm',
|
||
];
|
||
|
||
// Catalog of common ADIF modes with sensible RST defaults. When the user
|
||
// picks one on the right, the RSTs are pre-filled but stay editable.
|
||
const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [
|
||
{ name: 'SSB', sent: '59', rcvd: '59' },
|
||
{ name: 'CW', sent: '599', rcvd: '599' },
|
||
{ name: 'AM', sent: '59', rcvd: '59' },
|
||
{ name: 'FM', sent: '59', rcvd: '59' },
|
||
{ name: 'DIGITALVOICE', sent: '59', rcvd: '59' },
|
||
{ name: 'FT8', sent: '-10', rcvd: '-10' },
|
||
{ name: 'FT4', sent: '-10', rcvd: '-10' },
|
||
{ name: 'JS8', sent: '-10', rcvd: '-10' },
|
||
{ name: 'MSK144', sent: '+00', rcvd: '+00' },
|
||
{ name: 'JT65', sent: '-15', rcvd: '-15' },
|
||
{ name: 'JT9', sent: '-15', rcvd: '-15' },
|
||
{ name: 'Q65', sent: '-15', rcvd: '-15' },
|
||
{ name: 'FST4', sent: '-15', rcvd: '-15' },
|
||
{ name: 'FST4W', sent: '-15', rcvd: '-15' },
|
||
{ name: 'WSPR', sent: '-20', rcvd: '-20' },
|
||
{ name: 'RTTY', sent: '599', rcvd: '599' },
|
||
{ name: 'PSK31', sent: '599', rcvd: '599' },
|
||
{ name: 'PSK63', sent: '599', rcvd: '599' },
|
||
{ name: 'PSK125', sent: '599', rcvd: '599' },
|
||
{ name: 'OLIVIA', sent: '599', rcvd: '599' },
|
||
{ name: 'CONTESTI', sent: '599', rcvd: '599' },
|
||
{ name: 'MFSK', sent: '599', rcvd: '599' },
|
||
{ name: 'THROB', sent: '599', rcvd: '599' },
|
||
{ name: 'HELL', sent: '599', rcvd: '599' },
|
||
{ name: 'PACKET', sent: '599', rcvd: '599' },
|
||
{ name: 'PACTOR', sent: '599', rcvd: '599' },
|
||
{ name: 'VARA', sent: '599', rcvd: '599' },
|
||
{ name: 'VARA HF', sent: '599', rcvd: '599' },
|
||
{ name: 'ARDOP', sent: '599', rcvd: '599' },
|
||
{ name: 'ATV', sent: '59', rcvd: '59' },
|
||
{ name: 'SSTV', sent: '59', rcvd: '59' },
|
||
{ name: 'C4FM', sent: '59', rcvd: '59' },
|
||
{ name: 'DSTAR', sent: '59', rcvd: '59' },
|
||
{ name: 'DMR', sent: '59', rcvd: '59' },
|
||
{ name: 'FUSION', sent: '59', rcvd: '59' },
|
||
];
|
||
|
||
const emptyProfile = (): Profile => ({
|
||
id: 0,
|
||
name: '',
|
||
callsign: '', operator: '', owner_callsign: '',
|
||
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;
|
||
}
|
||
|
||
// Pretty little card showing what OpsLog will stamp on each QSO based on
|
||
// the callsign + grid in the Station Information form. Debounces the
|
||
// backend resolver so we don't fire on every keystroke; refreshes when
|
||
// inputs change. Empty card when no callsign yet.
|
||
/* ====== 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 =
|
||
| 'general'
|
||
| 'email'
|
||
| 'station'
|
||
| 'profiles'
|
||
| 'operating'
|
||
| 'confirmations'
|
||
| 'external-services'
|
||
| 'udp'
|
||
| 'lookup'
|
||
| 'lists-bands'
|
||
| 'lists-modes'
|
||
| 'cluster'
|
||
| 'backup'
|
||
| 'database'
|
||
| 'awards'
|
||
| 'cat'
|
||
| 'rotator'
|
||
| 'winkeyer'
|
||
| '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', id: 'profiles' },
|
||
{ kind: 'item', label: 'Operating conditions', id: 'operating' },
|
||
{ kind: 'item', label: 'Confirmations', id: 'confirmations' },
|
||
{ kind: 'item', label: 'External services', id: 'external-services' },
|
||
],
|
||
},
|
||
{
|
||
kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [
|
||
{ kind: 'item', label: 'General', id: 'general' },
|
||
{ kind: 'item', label: 'E-mail (SMTP)', id: 'email' },
|
||
{ 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: 'UDP integrations', id: 'udp' },
|
||
{ kind: 'item', label: 'Database', id: 'database' },
|
||
{ 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: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
|
||
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
||
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
|
||
],
|
||
},
|
||
];
|
||
|
||
// Map section id → friendly name (used in breadcrumb / placeholders).
|
||
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||
station: 'Station Information',
|
||
profiles: 'Profiles',
|
||
operating: 'Operating conditions',
|
||
confirmations: 'Confirmations',
|
||
'external-services': 'External services',
|
||
lookup: 'Callsign Lookup',
|
||
'lists-bands': 'Bands',
|
||
'lists-modes': 'Modes & default RST',
|
||
cluster: 'DX Cluster',
|
||
backup: 'Database backup',
|
||
database: 'Database',
|
||
udp: 'UDP integrations',
|
||
awards: 'Awards',
|
||
cat: 'CAT interface',
|
||
rotator: 'Rotator',
|
||
winkeyer: 'CW Keyer (WinKeyer)',
|
||
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>
|
||
);
|
||
}
|
||
|
||
// ProfileScopeNote flags that the panel's settings are saved per-profile, so
|
||
// the user knows which operating identity (F4BPO / TM2Q / …) they're editing.
|
||
function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: string } }) {
|
||
return (
|
||
<div className="-mt-2 mb-4">
|
||
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 text-primary text-xs px-2.5 py-1">
|
||
<User className="size-3.5" />
|
||
Saved for profile <strong className="font-semibold">{profile?.name || '—'}</strong>
|
||
{profile?.callsign ? <span className="font-mono opacity-80">({profile.callsign})</span> : null}
|
||
— switch profiles to edit another identity.
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||
const label = SECTION_LABELS[id] ?? id;
|
||
const IconCmp = Icon ?? Construction;
|
||
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: [], rst_phone: [], rst_cw: [], rst_digital: [] });
|
||
// RST report lists edited as free text (one/space-separated values).
|
||
const [rstText, setRstText] = useState({ phone: '', cw: '', digital: '' });
|
||
// Custom band drafts (catalog covers ADIF spec but the user may have
|
||
// exotic or experimental bands not listed).
|
||
const [bandDraft, setBandDraft] = useState('');
|
||
const [modeDraft, setModeDraft] = useState('');
|
||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
|
||
digital_default: 'FT8',
|
||
});
|
||
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);
|
||
|
||
// WinKeyer CW keyer settings + macro editor.
|
||
type WKMac = { label: string; text: string };
|
||
type WKSettings = {
|
||
enabled: boolean; engine: string; esc_clears_call: boolean;
|
||
port: string; baud: number; wpm: number; weight: number;
|
||
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
|
||
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
|
||
use_ptt: boolean; serial_echo: boolean; macros: WKMac[];
|
||
};
|
||
const [wk, setWk] = useState<WKSettings>({
|
||
enabled: false, engine: 'winkeyer', esc_clears_call: true,
|
||
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
|
||
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
|
||
swap: false, autospace: true, use_ptt: false, serial_echo: true, macros: [],
|
||
});
|
||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||
|
||
// ── Audio (DVK + QSO recorder) ──
|
||
type AudioSettings = {
|
||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
||
from_gain: number; mic_gain: number;
|
||
};
|
||
type AudioDev = { id: string; name: string; default: boolean };
|
||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
||
from_gain: 100, mic_gain: 100,
|
||
});
|
||
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
|
||
const setAudioField = (patch: Partial<AudioSettings>) => setAudioCfg((s) => ({ ...s, ...patch }));
|
||
const reloadAudioDevices = () => {
|
||
ListAudioInputDevices().then((d) => setAudioInputs((d ?? []) as AudioDev[])).catch(() => {});
|
||
ListAudioOutputDevices().then((d) => setAudioOutputs((d ?? []) as AudioDev[])).catch(() => {});
|
||
};
|
||
// DVK voice-keyer messages (F1–F6).
|
||
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||
const [dvkErr, setDvkErr] = useState('');
|
||
|
||
// General behaviour prefs (machine-local, applied live via localStorage).
|
||
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||
|
||
// E-mail / SMTP (send QSO recordings).
|
||
type EmailCfg = {
|
||
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
|
||
from: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
|
||
};
|
||
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
|
||
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
|
||
from: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||
});
|
||
const [emailMsg, setEmailMsg] = useState('');
|
||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||
// ClubLog Country File (cty.xml) exception status.
|
||
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
|
||
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
|
||
const [clubBusy, setClubBusy] = useState(false);
|
||
const [clubErr, setClubErr] = useState('');
|
||
useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []);
|
||
const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); };
|
||
useEffect(() => {
|
||
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
||
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
|
||
return () => { off?.(); };
|
||
}, []);
|
||
|
||
type QSLDefaults = {
|
||
qsl_sent: string; qsl_rcvd: string;
|
||
lotw_sent: string; lotw_rcvd: string;
|
||
eqsl_sent: string; eqsl_rcvd: string;
|
||
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
|
||
qrzcom_confirmed: string;
|
||
};
|
||
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
||
qsl_sent: '', qsl_rcvd: '',
|
||
lotw_sent: '', lotw_rcvd: '',
|
||
eqsl_sent: '', eqsl_rcvd: '',
|
||
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
||
qrzcom_confirmed: '',
|
||
});
|
||
|
||
// External services (logbook upload). One block per service; only QRZ is
|
||
// wired today. upload_mode is 'immediate' | 'delayed' (per-service).
|
||
type ExtServiceCfg = {
|
||
api_key: string; email: string; username: string; password: string; callsign: string;
|
||
force_station_callsign: string;
|
||
tqsl_path: string; station_location: string; key_password: string;
|
||
upload_flag: string; write_log: boolean;
|
||
auto_upload: boolean; upload_mode: string;
|
||
};
|
||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
|
||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||
api_key: '', email: '', username: '', password: '', callsign: '',
|
||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||
upload_flag: 'R', write_log: false,
|
||
auto_upload: false, upload_mode: 'immediate',
|
||
});
|
||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(),
|
||
});
|
||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||
const [qrzTesting, setQrzTesting] = useState(false);
|
||
const [clublogTest, setClublogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||
const [clublogTesting, setClublogTesting] = useState(false);
|
||
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||
const [lotwTesting, setLotwTesting] = useState(false);
|
||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||
// Active tab in the External Services panel — lifted here because
|
||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw' | 'pota'>('qrz');
|
||
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||
const [potaToken, setPotaToken] = useState('');
|
||
const [potaBusy, setPotaBusy] = useState(false);
|
||
const [potaResult, setPotaResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||
useEffect(() => { GetPOTAToken().then((t) => setPotaToken(t || '')).catch(() => {}); }, []);
|
||
|
||
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
|
||
enabled: false, folder: '', rotation: 5, zip: false,
|
||
last_backup_at: '', default_folder: '',
|
||
} as any);
|
||
const [backupRunning, setBackupRunning] = useState(false);
|
||
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||
|
||
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
|
||
const [dbMsg, setDbMsg] = useState('');
|
||
|
||
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(() => {
|
||
const unsub = EventsOn('cluster:state', async (st: any) => {
|
||
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
|
||
try {
|
||
const list = await ListClusterServers();
|
||
setClusterServers((list ?? []) as ClusterServer[]);
|
||
} catch {}
|
||
});
|
||
return () => { unsub?.(); };
|
||
}, []);
|
||
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, b, qd, es] = await Promise.all([
|
||
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
||
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
||
]);
|
||
setLookup(l);
|
||
setActiveProfile(ap as Profile);
|
||
setLists(ls);
|
||
setRstText({
|
||
phone: ((ls as any).rst_phone ?? []).join(' '),
|
||
cw: ((ls as any).rst_cw ?? []).join(' '),
|
||
digital: ((ls as any).rst_digital ?? []).join(' '),
|
||
});
|
||
await reloadProfiles();
|
||
await reloadClusterServers();
|
||
setCatCfg(c);
|
||
setRotator(r);
|
||
setBackupCfg(b as any);
|
||
setQslDefaults(qd as any);
|
||
setExtSvc(es as any);
|
||
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
||
try {
|
||
const locs: any = await ListTQSLStationLocations();
|
||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
||
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
||
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||
reloadAudioDevices();
|
||
reloadDvk();
|
||
} catch (e: any) {
|
||
setErr(String(e?.message ?? e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
// Auto-fill the active profile's MY_* DXCC metadata from the station
|
||
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
|
||
// are derived values, so they always recompute when the callsign or grid
|
||
// changes — the user can still edit a field, it just re-populates when the
|
||
// source changes. Debounced so we don't hammer cty.dat while typing.
|
||
useEffect(() => {
|
||
const call = (activeProfile?.callsign ?? '').trim();
|
||
if (!call) return;
|
||
const grid = (activeProfile?.my_grid ?? '').trim();
|
||
const t = window.setTimeout(async () => {
|
||
try {
|
||
const i: any = await ComputeStationInfo(call, grid);
|
||
setActiveProfile((p) => {
|
||
if (!p) return p;
|
||
const patch: any = {};
|
||
if (i.country) patch.my_country = i.country;
|
||
if (i.dxcc) patch.my_dxcc = i.dxcc;
|
||
if (i.cqz) patch.my_cqz = i.cqz;
|
||
if (i.ituz) patch.my_ituz = i.ituz;
|
||
if (i.lat) patch.my_lat = i.lat;
|
||
if (i.lon) patch.my_lon = i.lon;
|
||
// Only re-render when a value actually changed (prevents loops).
|
||
const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]);
|
||
return changed ? { ...p, ...patch } : p;
|
||
});
|
||
} catch { /* offline / unknown prefix — leave fields as-is */ }
|
||
}, 250);
|
||
return () => window.clearTimeout(t);
|
||
}, [activeProfile?.callsign, activeProfile?.my_grid]);
|
||
|
||
// ── Band selection helpers (dual-list shuttle) ──────────────────────────
|
||
function addBand(tag: string) {
|
||
const b = tag.trim().toLowerCase();
|
||
if (!b) return;
|
||
setLists((l) => {
|
||
if ((l.bands ?? []).includes(b)) return l;
|
||
return { ...l, bands: [...(l.bands ?? []), b] };
|
||
});
|
||
}
|
||
function removeBand(i: number) {
|
||
setLists((l) => {
|
||
const next = [...(l.bands ?? [])];
|
||
next.splice(i, 1);
|
||
return { ...l, bands: next };
|
||
});
|
||
}
|
||
function moveBand(i: number, dir: -1 | 1) {
|
||
setLists((l) => {
|
||
const next = [...(l.bands ?? [])];
|
||
const j = i + dir;
|
||
if (j < 0 || j >= next.length) return l;
|
||
[next[i], next[j]] = [next[j], next[i]];
|
||
return { ...l, bands: next };
|
||
});
|
||
}
|
||
|
||
// ── Mode helpers ────────────────────────────────────────────────────────
|
||
function addMode() {
|
||
setLists((l) => ({
|
||
...l,
|
||
modes: [...(l.modes ?? []), { name: '', default_rst_sent: '59', default_rst_rcvd: '59' }],
|
||
}));
|
||
}
|
||
function addModeFromCatalog(m: { name: string; sent: string; rcvd: string }) {
|
||
setLists((l) => {
|
||
if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === m.name)) return l;
|
||
return {
|
||
...l,
|
||
modes: [...(l.modes ?? []), { name: m.name, default_rst_sent: m.sent, default_rst_rcvd: m.rcvd }],
|
||
};
|
||
});
|
||
}
|
||
function addCustomMode(name: string) {
|
||
const n = name.trim().toUpperCase();
|
||
if (!n) return;
|
||
setLists((l) => {
|
||
if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === n)) return l;
|
||
return {
|
||
...l,
|
||
modes: [...(l.modes ?? []), { name: n, default_rst_sent: '59', default_rst_rcvd: '59' }],
|
||
};
|
||
});
|
||
}
|
||
function removeMode(i: number) {
|
||
setLists((l) => {
|
||
const next = [...(l.modes ?? [])];
|
||
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(close = true) {
|
||
setSaving(true); setErr(''); setMsg('');
|
||
try {
|
||
// Bands: dedup, lowercase, trim. Order = user's drag order.
|
||
const seen = new Set<string>();
|
||
const bands: string[] = [];
|
||
for (const raw of lists.bands ?? []) {
|
||
const b = (raw ?? '').trim().toLowerCase();
|
||
if (b && !seen.has(b)) { seen.add(b); bands.push(b); }
|
||
}
|
||
const modes = (lists.modes ?? [])
|
||
.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 !== '');
|
||
const splitList = (s: string) => s.split(/[\s,]+/).map((x) => x.trim()).filter(Boolean);
|
||
await SaveListsSettings({
|
||
bands, modes,
|
||
rst_phone: splitList(rstText.phone),
|
||
rst_cw: splitList(rstText.cw),
|
||
rst_digital: splitList(rstText.digital),
|
||
} 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 SaveWinkeyerSettings(wk as any);
|
||
await SaveAudioSettings(audioCfg as any);
|
||
await SaveEmailSettings(emailCfg as any);
|
||
await SaveBackupSettings(backupCfg as any);
|
||
await SaveQSLDefaults(qslDefaults as any);
|
||
await SaveExternalServices(extSvc as any);
|
||
await SetClusterAutoConnect(clusterAutoConnect);
|
||
|
||
setMsg('Settings saved.');
|
||
onSaved();
|
||
if (close) setTimeout(onClose, 500);
|
||
else setTimeout(() => setMsg(''), 2000);
|
||
} 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 className="text-[10px] text-muted-foreground">What's transmitted (ADIF STATION_CALLSIGN).</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Operator callsign</Label>
|
||
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
|
||
<div className="text-[10px] text-muted-foreground">Who's at the radio (ADIF OPERATOR).</div>
|
||
</div>
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>Owner callsign</Label>
|
||
<Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
|
||
<div className="text-[10px] text-muted-foreground">Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).</div>
|
||
</div>
|
||
<div className="col-span-2 text-[10px] text-muted-foreground uppercase tracking-wider mt-1">
|
||
Auto-filled from the callsign — editable (stamped as MY_* on each QSO)
|
||
</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="grid grid-cols-3 gap-2 col-span-2">
|
||
<div className="space-y-1">
|
||
<Label>DXCC #</Label>
|
||
<Input type="number" className="font-mono" value={(p as any).my_dxcc ?? ''}
|
||
onChange={(e) => updateActive({ my_dxcc: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>CQ zone</Label>
|
||
<Input type="number" className="font-mono" value={(p as any).my_cqz ?? ''}
|
||
onChange={(e) => updateActive({ my_cqz: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>ITU zone</Label>
|
||
<Input type="number" className="font-mono" value={(p as any).my_ituz ?? ''}
|
||
onChange={(e) => updateActive({ my_ituz: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Latitude</Label>
|
||
<Input type="number" step="0.001" className="font-mono" value={(p as any).my_lat ?? ''}
|
||
onChange={(e) => updateActive({ my_lat: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Longitude</Label>
|
||
<Input type="number" step="0.001" className="font-mono" value={(p as any).my_lon ?? ''}
|
||
onChange={(e) => updateActive({ my_lon: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
|
||
</div>
|
||
</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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// 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();
|
||
// Per-profile settings follow the active identity — reload the panels
|
||
// that are now scoped to the newly-active profile.
|
||
const [ap, qd, es] = await Promise.all([GetActiveProfile(), GetQSLDefaults(), GetExternalServices()]);
|
||
setActiveProfile(ap as Profile);
|
||
setQslDefaults(qd as any);
|
||
setExtSvc(es as any);
|
||
onSaved();
|
||
}
|
||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function profileRemove() {
|
||
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() {
|
||
const selected = lists.bands ?? [];
|
||
const selectedSet = new Set(selected.map((b) => (b ?? '').toLowerCase()));
|
||
const available = BAND_CATALOG.filter((b) => !selectedSet.has(b.toLowerCase()));
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title="Bands"
|
||
hint="Pick the bands you actually use. The entry strip, the band-slot grid and the band-map switcher only show what's on the right. Order on the right = display order."
|
||
/>
|
||
<div className="grid grid-cols-[1fr_auto_1fr] gap-3 max-w-3xl">
|
||
{/* Left: available catalog */}
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||
Available
|
||
</div>
|
||
<div className="max-h-[320px] overflow-y-auto divide-y divide-border">
|
||
{available.length === 0 ? (
|
||
<div className="px-3 py-2 text-xs text-muted-foreground italic">All catalog bands selected.</div>
|
||
) : (
|
||
available.map((b) => (
|
||
<button
|
||
key={b}
|
||
type="button"
|
||
onDoubleClick={() => addBand(b)}
|
||
onClick={() => addBand(b)}
|
||
className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors"
|
||
>
|
||
{b}
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
<div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
|
||
<Input
|
||
value={bandDraft}
|
||
onChange={(e) => setBandDraft(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
addBand(bandDraft);
|
||
setBandDraft('');
|
||
}
|
||
}}
|
||
placeholder="Custom band (e.g. 4m)"
|
||
className="font-mono h-7 text-xs"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7"
|
||
onClick={() => { addBand(bandDraft); setBandDraft(''); }}
|
||
disabled={!bandDraft.trim()}
|
||
>
|
||
<Plus className="size-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Center: shuttle hint */}
|
||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||
<ArrowRight className="size-4" />
|
||
<ArrowLeft className="size-4" />
|
||
</div>
|
||
|
||
{/* Right: selected */}
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground flex items-center justify-between">
|
||
<span>Selected ({selected.length})</span>
|
||
</div>
|
||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
|
||
{selected.length === 0 ? (
|
||
<div className="px-3 py-2 text-xs text-muted-foreground italic">
|
||
No band selected — pick from the left.
|
||
</div>
|
||
) : (
|
||
selected.map((b, i) => (
|
||
<div key={`${b}-${i}`} className="grid grid-cols-[auto_1fr_auto] items-center gap-1 px-2 py-1">
|
||
<div className="flex gap-0.5">
|
||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, -1)} disabled={i === 0}>
|
||
<ArrowUp className="size-3" />
|
||
</Button>
|
||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, 1)} disabled={i === selected.length - 1}>
|
||
<ArrowDown className="size-3" />
|
||
</Button>
|
||
</div>
|
||
<span className="font-mono text-sm">{b}</span>
|
||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeBand(i)}>
|
||
<Trash2 className="size-3" />
|
||
</Button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ModesPanel() {
|
||
const selected = lists.modes ?? [];
|
||
const selectedSet = new Set(selected.map((m) => (m.name ?? '').toUpperCase()));
|
||
const available = MODE_CATALOG.filter((m) => !selectedSet.has(m.name));
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title="Modes & default RST"
|
||
hint="Pick the modes you actually use on the right. Anywhere the UI shows a mode picker, it iterates the right column. When you select a mode in the entry form, RST sent/rcvd auto-fill with the defaults below (unless you've typed something)."
|
||
/>
|
||
<div className="grid grid-cols-[1fr_auto_1.5fr] gap-3 max-w-4xl">
|
||
{/* Left: available catalog */}
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||
Available
|
||
</div>
|
||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
|
||
{available.length === 0 ? (
|
||
<div className="px-3 py-2 text-xs text-muted-foreground italic">All catalog modes selected.</div>
|
||
) : (
|
||
available.map((m) => (
|
||
<button
|
||
key={m.name}
|
||
type="button"
|
||
onDoubleClick={() => addModeFromCatalog(m)}
|
||
onClick={() => addModeFromCatalog(m)}
|
||
className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors flex items-center justify-between gap-2"
|
||
title={`Default RST: ${m.sent} / ${m.rcvd}`}
|
||
>
|
||
<span>{m.name}</span>
|
||
<span className="text-[10px] text-muted-foreground">{m.sent}/{m.rcvd}</span>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
<div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
|
||
<Input
|
||
value={modeDraft}
|
||
onChange={(e) => setModeDraft(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
addCustomMode(modeDraft);
|
||
setModeDraft('');
|
||
}
|
||
}}
|
||
placeholder="Custom mode"
|
||
className="font-mono uppercase h-7 text-xs"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7"
|
||
onClick={() => { addCustomMode(modeDraft); setModeDraft(''); }}
|
||
disabled={!modeDraft.trim()}
|
||
>
|
||
<Plus className="size-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Center: shuttle hint */}
|
||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||
<ArrowRight className="size-4" />
|
||
<ArrowLeft className="size-4" />
|
||
</div>
|
||
|
||
{/* Right: selected with editable RST */}
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||
<span className="w-12">Order</span>
|
||
<span>Mode</span>
|
||
<span>RST snt</span>
|
||
<span>RST rcv</span>
|
||
<span className="w-6"></span>
|
||
</div>
|
||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
|
||
{selected.length === 0 ? (
|
||
<div className="px-3 py-2 text-xs text-muted-foreground italic">
|
||
No mode selected — pick from the left.
|
||
</div>
|
||
) : (
|
||
selected.map((m, i) => (
|
||
<div key={i} className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1 items-center">
|
||
<div className="flex gap-0.5 w-12">
|
||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, -1)} disabled={i === 0}>
|
||
<ArrowUp className="size-3" />
|
||
</Button>
|
||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === selected.length - 1}>
|
||
<ArrowDown className="size-3" />
|
||
</Button>
|
||
</div>
|
||
<Input className="font-mono uppercase h-7" value={m.name} onChange={(e) => updateMode(i, { name: e.target.value })} placeholder="SSB" />
|
||
<Input className="font-mono h-7" value={m.default_rst_sent ?? ''} onChange={(e) => updateMode(i, { default_rst_sent: e.target.value })} placeholder="59" />
|
||
<Input className="font-mono h-7" value={m.default_rst_rcvd ?? ''} onChange={(e) => updateMode(i, { default_rst_rcvd: e.target.value })} placeholder="59" />
|
||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeMode(i)}>
|
||
<Trash2 className="size-3" />
|
||
</Button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
<div className="px-3 py-1.5 border-t border-border bg-muted/40">
|
||
<Button variant="ghost" size="sm" onClick={addMode} className="h-6 text-xs">
|
||
<Plus className="size-3" /> Add blank row
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* RST report lists — the dropdown choices in the entry form. */}
|
||
<div className="mt-6 max-w-4xl">
|
||
<div className="text-sm font-semibold mb-1">RST report lists</div>
|
||
<div className="text-[11px] text-muted-foreground mb-2">
|
||
The choices offered in the entry form's RST dropdowns, per mode family. One value per line (or space-separated). The first one is the top of the list.
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">Phone (SSB/AM/FM)</Label>
|
||
<Textarea rows={8} className="font-mono text-xs" value={rstText.phone} onChange={(e) => setRstText((s) => ({ ...s, phone: e.target.value }))} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">CW / RTTY / PSK</Label>
|
||
<Textarea rows={8} className="font-mono text-xs" value={rstText.cw} onChange={(e) => setRstText((s) => ({ ...s, cw: e.target.value }))} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">Digital (FT8/FT4/JT…) — dB</Label>
|
||
<Textarea rows={8} className="font-mono text-xs" value={rstText.digital} onChange={(e) => setRstText((s) => ({ ...s, digital: e.target.value }))} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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 — OpsLog 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.
|
||
OpsLog 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 OpsLog 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="OpsLog 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 WinkeyerPanel() {
|
||
const setMacro = (i: number, patch: Partial<WKMac>) => setWk((s) => {
|
||
const macros = [...s.macros];
|
||
while (macros.length <= i) macros.push({ label: '', text: '' });
|
||
macros[i] = { ...macros[i], ...patch };
|
||
return { ...s, macros };
|
||
});
|
||
const num = (v: string, d: number) => { const n = parseInt(v, 10); return isNaN(n) ? d : n; };
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title="CW Keyer (WinKeyer)"
|
||
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
|
||
/>
|
||
<div className="space-y-4 max-w-2xl">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={wk.enabled} onCheckedChange={(c) => setWkField({ enabled: !!c })} />
|
||
Enable CW keyer (shows the keyer panel)
|
||
</label>
|
||
|
||
<div className="grid grid-cols-4 gap-3 items-end">
|
||
<div className="space-y-1">
|
||
<Label>Keyer engine</Label>
|
||
<Select value={wk.engine} onValueChange={(v) => setWkField({ engine: v })}>
|
||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
||
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
|
||
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
|
||
ESC clears the callsign too (otherwise ESC only stops transmission)
|
||
</label>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>Serial port</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
|
||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— COM port —" /></SelectTrigger>
|
||
<SelectContent>
|
||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button variant="ghost" size="sm" className="h-8 px-2" title="Reload ports"
|
||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||
<ArrowDown className="size-3.5 rotate-90" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Baud</Label>
|
||
<Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
|
||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{[1200, 9600].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Speed (WPM)</Label>
|
||
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>Weight</Label>
|
||
<Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Lead-in (ms)</Label>
|
||
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Tail (ms)</Label>
|
||
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Ratio (33-66)</Label>
|
||
<Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Farnsworth</Label>
|
||
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Sidetone (Hz)</Label>
|
||
<Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>Paddle mode</Label>
|
||
<Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}>
|
||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="iambic_b">Iambic B</SelectItem>
|
||
<SelectItem value="iambic_a">Iambic A</SelectItem>
|
||
<SelectItem value="ultimatic">Ultimatic</SelectItem>
|
||
<SelectItem value="bug">Bug</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> Swap paddles
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> Auto-space
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> Key PTT
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo
|
||
</label>
|
||
</div>
|
||
|
||
{/* Macro editor */}
|
||
<div className="border-t border-border/60 pt-3">
|
||
<Label className="text-sm font-medium">CW message macros (F1…)</Label>
|
||
<p className="text-[11px] text-muted-foreground mb-2">
|
||
Use variables: <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call.
|
||
</p>
|
||
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
|
||
{Array.from({ length: 12 }).map((_, i) => {
|
||
const m = wk.macros[i] ?? { label: '', text: '' };
|
||
return (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<span className="font-mono text-[10px] text-primary font-semibold w-6 shrink-0">F{i + 1}</span>
|
||
<Input className="h-8 w-28 shrink-0 text-xs" placeholder="Label" value={m.label} onChange={(e) => setMacro(i, { label: e.target.value })} />
|
||
<Input className="h-8 flex-1 font-mono text-xs" placeholder="CQ CQ DE <MY_CALL> K" value={m.text} onChange={(e) => setMacro(i, { text: e.target.value })} />
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function statusForServer(id: number): ClusterServerStatus | undefined {
|
||
return clusterStatuses.find((s) => (s.server_id as number) === id);
|
||
}
|
||
|
||
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)); }
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ConfirmationsPanel() {
|
||
// ADIF status codes. FULL set for paper QSL/eQSL/Clublog/HRDLog,
|
||
// SIMPLE Y/N set for LoTW (the only values the LoTW protocol returns).
|
||
const FULL_OPTIONS = [
|
||
{ value: '_', label: '— leave blank —' },
|
||
{ value: 'Y', label: 'Y (yes)' },
|
||
{ value: 'N', label: 'N (no)' },
|
||
{ value: 'R', label: 'R (requested)' },
|
||
{ value: 'Q', label: 'Q (queued)' },
|
||
{ value: 'I', label: 'I (ignore)' },
|
||
];
|
||
// LoTW / Clublog / HRDLog also use ADIF-style status codes — keep
|
||
// R (requested) available so users can mark "queued for upload"
|
||
// and filter on it later.
|
||
|
||
// Renderer inlined as a constant — declaring this as a function
|
||
// INSIDE ConfirmationsPanel would re-instantiate the component on
|
||
// every render, which unmounts and re-mounts the Radix Select
|
||
// (closing it the moment you click the trigger).
|
||
const renderSelect = (
|
||
key: keyof QSLDefaults,
|
||
options: { value: string; label: string }[],
|
||
) => (
|
||
<Select
|
||
value={qslDefaults[key] || '_'}
|
||
onValueChange={(v) => setQslDefaults((d) => ({ ...d, [key]: v === '_' ? '' : v }))}
|
||
>
|
||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{options.map((o) => (
|
||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title="Confirmations"
|
||
hint="Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty."
|
||
/>
|
||
<ProfileScopeNote profile={activeProfileObj} />
|
||
<div className="space-y-3 max-w-2xl">
|
||
{/* Paper QSL */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">Paper QSL</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||
{renderSelect('qsl_sent', FULL_OPTIONS)}
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
|
||
{renderSelect('qsl_rcvd', FULL_OPTIONS)}
|
||
</div>
|
||
</div>
|
||
{/* eQSL */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">eQSL</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||
{renderSelect('eqsl_sent', FULL_OPTIONS)}
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
|
||
{renderSelect('eqsl_rcvd', FULL_OPTIONS)}
|
||
</div>
|
||
</div>
|
||
{/* LoTW */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">LoTW</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||
{renderSelect('lotw_sent', FULL_OPTIONS)}
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
|
||
{renderSelect('lotw_rcvd', FULL_OPTIONS)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<div className="text-[11px] text-muted-foreground">
|
||
"Sent" = the QSO's upload status to the service; "N" is typical so OpsLog tracks which QSOs still need uploading. Club Log & HRDLog are upload-only (no "rcvd" in ADIF); QRZ.com also has "Rcvd" = confirmed back.
|
||
</div>
|
||
{/* Clublog */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">Clublog</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||
{renderSelect('clublog_status', FULL_OPTIONS)}
|
||
</div>
|
||
<div />
|
||
</div>
|
||
{/* HRDLog */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">HRDLog</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||
{renderSelect('hrdlog_status', FULL_OPTIONS)}
|
||
</div>
|
||
<div />
|
||
</div>
|
||
{/* QRZ.com */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">QRZ.com</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||
{renderSelect('qrzcom_status', FULL_OPTIONS)}
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
|
||
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function UDPIntegrationsPanelWrapper() {
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title="UDP integrations"
|
||
hint="Listen for QSO logs from WSJT-X / JTDX / MSHV, ADIF messages from JTAlert/GridTracker, or simple callsign packets from external tools. Outbound connections forward every QSO you log to a remote listener (Cloudlog UDP, N1MM, …)."
|
||
/>
|
||
<UDPIntegrationsPanel onError={(m) => setErr(m)} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
function OperatingPanelWrapper() {
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title="Operating conditions"
|
||
hint="Define your rigs and the antennas you use on each band. The entry strip will auto-fill MY_RIG and MY_ANTENNA based on the default antenna for the band you're operating on."
|
||
/>
|
||
<OperatingPanel
|
||
bands={lists.bands ?? []}
|
||
onError={(m) => setErr(m)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function BackupPanel() {
|
||
const fmtLast = (iso: string) => {
|
||
if (!iso) return 'never';
|
||
const d = new Date(iso);
|
||
if (isNaN(d.getTime())) return iso;
|
||
const p = (n: number) => String(n).padStart(2, '0');
|
||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())} UTC`;
|
||
};
|
||
const effectiveFolder = (backupCfg.folder || backupCfg.default_folder || '').replace(/\\/g, '/');
|
||
async function backupNow() {
|
||
setBackupRunning(true); setBackupResult(null);
|
||
try {
|
||
// Save current draft first so the backup runs with the values
|
||
// the user just typed (folder, rotation, zip) — otherwise the
|
||
// backend would use stale persisted config.
|
||
await SaveBackupSettings(backupCfg as any);
|
||
const path = await RunBackupNow();
|
||
setBackupResult({ ok: true, msg: 'Backup written to ' + path });
|
||
const refreshed = await GetBackupSettings();
|
||
setBackupCfg(refreshed as any);
|
||
} catch (e: any) {
|
||
setBackupResult({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally { setBackupRunning(false); }
|
||
}
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title="Database backup"
|
||
hint="OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."
|
||
/>
|
||
<div className="space-y-4 max-w-xl">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={!!backupCfg.enabled}
|
||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, enabled: !!c }))}
|
||
/>
|
||
<span>Automatic backup when closing OpsLog (max once per day)</span>
|
||
</label>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-muted-foreground">Backup folder</Label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
className="font-mono text-xs flex-1"
|
||
placeholder={backupCfg.default_folder || 'leave empty for default'}
|
||
value={backupCfg.folder ?? ''}
|
||
onChange={(e) => setBackupCfg((b) => ({ ...b, folder: e.target.value }))}
|
||
/>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={async () => {
|
||
try {
|
||
const p = await PickBackupFolder();
|
||
if (p) setBackupCfg((b) => ({ ...b, folder: p }));
|
||
} catch (e: any) {
|
||
setBackupResult({ ok: false, msg: String(e?.message ?? e) });
|
||
}
|
||
}}
|
||
>
|
||
Browse…
|
||
</Button>
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground">
|
||
{backupCfg.folder
|
||
? <>Effective folder: <span className="font-mono">{effectiveFolder}</span></>
|
||
: <>If empty, OpsLog uses the default: <span className="font-mono">{(backupCfg.default_folder ?? '').replace(/\\/g, '/')}</span></>
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-end gap-3">
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-muted-foreground">Rotation (copies to keep)</Label>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={365}
|
||
className="w-24 font-mono text-xs"
|
||
value={backupCfg.rotation || 5}
|
||
onChange={(e) => {
|
||
const n = Number(e.target.value);
|
||
if (Number.isFinite(n) && n > 0) setBackupCfg((b) => ({ ...b, rotation: Math.floor(n) }));
|
||
}}
|
||
/>
|
||
</div>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
|
||
<Checkbox
|
||
checked={!!backupCfg.zip}
|
||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, zip: !!c }))}
|
||
/>
|
||
<span>ZIP backup (smaller file)</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
|
||
<Button size="sm" onClick={backupNow} disabled={backupRunning}>
|
||
{backupRunning ? 'Backing up…' : 'Backup now'}
|
||
</Button>
|
||
<span className="text-xs text-muted-foreground">
|
||
Last backup: <strong className="text-foreground">{fmtLast(backupCfg.last_backup_at)}</strong>
|
||
</span>
|
||
</div>
|
||
|
||
{backupResult && (
|
||
<div className={cn(
|
||
'text-xs px-3 py-2 rounded-md border',
|
||
backupResult.ok
|
||
? 'bg-emerald-50 border-emerald-300 text-emerald-800'
|
||
: 'bg-rose-50 border-rose-300 text-rose-800',
|
||
)}>
|
||
{backupResult.msg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ExternalServicesPanel() {
|
||
const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [
|
||
{ k: 'qrz', label: 'QRZ.COM', ready: true },
|
||
{ k: 'clublog', label: 'CLUBLOG', ready: true },
|
||
{ k: 'hrdlog', label: 'HRDLOG.NET' },
|
||
{ k: 'eqsl', label: 'EQSL' },
|
||
{ k: 'hamqth', label: 'HAMQTH' },
|
||
{ k: 'lotw', label: 'LOTW', ready: true },
|
||
{ k: 'pota', label: 'POTA', ready: true },
|
||
];
|
||
|
||
async function syncPota() {
|
||
setPotaBusy(true);
|
||
setPotaResult(null);
|
||
try {
|
||
await SavePOTAToken(potaToken);
|
||
const r: any = await SyncPOTAHunterLog();
|
||
setPotaResult({ ok: true, msg: `${r.updated} QSO updated · ${r.already_tagged} already tagged · ${r.unmatched} unmatched (of ${r.fetched} hunter-log entries).` });
|
||
} catch (e: any) {
|
||
setPotaResult({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setPotaBusy(false);
|
||
}
|
||
}
|
||
const qrz = extSvc.qrz;
|
||
const setQrz = (patch: Partial<ExtServiceCfg>) =>
|
||
setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } }));
|
||
const clublog = extSvc.clublog;
|
||
const setClublog = (patch: Partial<ExtServiceCfg>) =>
|
||
setExtSvc((s) => ({ ...s, clublog: { ...s.clublog, ...patch } }));
|
||
|
||
async function testQrz() {
|
||
setQrzTesting(true);
|
||
setQrzTest(null);
|
||
try {
|
||
// Persist first so the backend test reads the key just typed.
|
||
await SaveExternalServices(extSvc as any);
|
||
const msg = await TestQRZUpload();
|
||
setQrzTest({ ok: true, msg });
|
||
} catch (e: any) {
|
||
setQrzTest({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setQrzTesting(false);
|
||
}
|
||
}
|
||
|
||
async function testClublog() {
|
||
setClublogTesting(true);
|
||
setClublogTest(null);
|
||
try {
|
||
await SaveExternalServices(extSvc as any);
|
||
const msg = await TestClublogUpload();
|
||
setClublogTest({ ok: true, msg });
|
||
} catch (e: any) {
|
||
setClublogTest({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setClublogTesting(false);
|
||
}
|
||
}
|
||
|
||
const lotw = extSvc.lotw;
|
||
const setLotw = (patch: Partial<ExtServiceCfg>) =>
|
||
setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } }));
|
||
|
||
async function refreshLocations() {
|
||
try {
|
||
const locs: any = await ListTQSLStationLocations();
|
||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||
} catch (e: any) {
|
||
setLotwTest({ ok: false, msg: String(e?.message ?? e) });
|
||
}
|
||
}
|
||
|
||
async function testLotw() {
|
||
setLotwTesting(true);
|
||
setLotwTest(null);
|
||
try {
|
||
await SaveExternalServices(extSvc as any);
|
||
const msg = await TestLoTWUpload();
|
||
setLotwTest({ ok: true, msg });
|
||
} catch (e: any) {
|
||
setLotwTest({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setLotwTesting(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title="External services"
|
||
hint="Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first)."
|
||
/>
|
||
|
||
<ProfileScopeNote profile={activeProfileObj} />
|
||
|
||
{/* Tab strip */}
|
||
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
||
{TABS.map((t) => (
|
||
<button
|
||
key={t.k}
|
||
type="button"
|
||
onClick={() => setExtSvcTab(t.k)}
|
||
className={cn(
|
||
'px-3 py-1.5 text-xs font-semibold rounded-t-md border-x border-t -mb-px transition-colors',
|
||
extSvcTab === t.k
|
||
? 'bg-card border-border text-foreground'
|
||
: 'bg-muted/40 border-transparent text-muted-foreground hover:text-foreground',
|
||
)}
|
||
>
|
||
{t.label}
|
||
{!t.ready && <span className="ml-1 text-[9px] opacity-60">soon</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{extSvcTab === 'qrz' ? (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">API key</Label>
|
||
<Input
|
||
value={qrz.api_key}
|
||
onChange={(e) => setQrz({ api_key: e.target.value })}
|
||
placeholder="QRZ.com logbook API key (XXXX-XXXX-XXXX-XXXX)"
|
||
className="font-mono text-xs"
|
||
/>
|
||
<Label className="text-sm">Force station callsign</Label>
|
||
<Input
|
||
value={qrz.force_station_callsign}
|
||
onChange={(e) => setQrz({ force_station_callsign: e.target.value.toUpperCase() })}
|
||
placeholder="e.g. F4BPO — optional"
|
||
className="font-mono text-xs"
|
||
/>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={qrz.auto_upload}
|
||
onCheckedChange={(c) => setQrz({ auto_upload: !!c })}
|
||
/>
|
||
Automatic upload on new QSO
|
||
</label>
|
||
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">Upload timing</Label>
|
||
<Select
|
||
value={qrz.upload_mode || 'immediate'}
|
||
onValueChange={(v) => setQrz({ upload_mode: v })}
|
||
>
|
||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="immediate">Immediate</SelectItem>
|
||
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
||
<SelectItem value="on_close">On app close (batch)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" onClick={testQrz} disabled={qrzTesting || !qrz.api_key}>
|
||
<UploadCloud className="size-3.5" /> {qrzTesting ? 'Testing…' : 'Test connection'}
|
||
</Button>
|
||
{qrzTest && (
|
||
<span className={cn('text-xs', qrzTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
||
{qrzTest.msg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : extSvcTab === 'clublog' ? (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">Account email</Label>
|
||
<Input
|
||
type="email"
|
||
value={clublog.email}
|
||
onChange={(e) => setClublog({ email: e.target.value })}
|
||
placeholder="your Club Log account email"
|
||
className="text-xs"
|
||
/>
|
||
<Label className="text-sm">Password</Label>
|
||
<Input
|
||
type="password"
|
||
value={clublog.password}
|
||
onChange={(e) => setClublog({ password: e.target.value })}
|
||
placeholder="Club Log account password"
|
||
className="text-xs"
|
||
/>
|
||
<Label className="text-sm">Logbook callsign</Label>
|
||
<Input
|
||
value={clublog.callsign}
|
||
onChange={(e) => setClublog({ callsign: e.target.value.toUpperCase() })}
|
||
placeholder="defaults to the active profile's callsign"
|
||
className="font-mono text-xs"
|
||
/>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={clublog.auto_upload}
|
||
onCheckedChange={(c) => setClublog({ auto_upload: !!c })}
|
||
/>
|
||
Automatic upload on new QSO
|
||
</label>
|
||
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">Upload timing</Label>
|
||
<Select
|
||
value={clublog.upload_mode || 'immediate'}
|
||
onValueChange={(v) => setClublog({ upload_mode: v })}
|
||
>
|
||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="immediate">Immediate</SelectItem>
|
||
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
||
<SelectItem value="on_close">On app close (batch)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" onClick={testClublog} disabled={clublogTesting}>
|
||
<UploadCloud className="size-3.5" /> {clublogTesting ? 'Testing…' : 'Test connection'}
|
||
</Button>
|
||
{clublogTest && (
|
||
<span className={cn('text-xs', clublogTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
||
{clublogTest.msg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : extSvcTab === 'lotw' ? (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">LoTW user</Label>
|
||
<Input
|
||
value={lotw.username}
|
||
onChange={(e) => setLotw({ username: e.target.value })}
|
||
placeholder="LoTW website login (for downloading confirmations)"
|
||
className="font-mono text-xs"
|
||
/>
|
||
<Label className="text-sm">LoTW password</Label>
|
||
<Input
|
||
type="password"
|
||
value={lotw.password}
|
||
onChange={(e) => setLotw({ password: e.target.value })}
|
||
placeholder="LoTW website password"
|
||
className="text-xs"
|
||
/>
|
||
<Label className="text-sm">TQSL path</Label>
|
||
<Input
|
||
value={lotw.tqsl_path}
|
||
onChange={(e) => setLotw({ tqsl_path: e.target.value })}
|
||
placeholder="C:\Program Files (x86)\TrustedQSL\tqsl.exe"
|
||
className="font-mono text-xs"
|
||
/>
|
||
<Label className="text-sm">Station location</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Select value={lotw.station_location || '_'} onValueChange={(v) => setLotw({ station_location: v === '_' ? '' : v })}>
|
||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— pick a TQSL location —" /></SelectTrigger>
|
||
<SelectContent>
|
||
{stationLocations.length === 0 && <SelectItem value="_" disabled>No TQSL locations found</SelectItem>}
|
||
{stationLocations.map((n) => <SelectItem key={n} value={n}>{n}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={refreshLocations} title="Reload locations from TQSL">
|
||
<ArrowDown className="size-3.5 rotate-90" />
|
||
</Button>
|
||
</div>
|
||
<Label className="text-sm">Force station callsign</Label>
|
||
<div>
|
||
<Input
|
||
value={lotw.force_station_callsign}
|
||
onChange={(e) => setLotw({ force_station_callsign: e.target.value.toUpperCase() })}
|
||
placeholder="e.g. F4BPO/P — leave blank to use the QSO's own call"
|
||
className="font-mono uppercase w-64"
|
||
/>
|
||
<div className="text-[10px] text-muted-foreground mt-1">
|
||
Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls
|
||
(F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.
|
||
</div>
|
||
</div>
|
||
<Label className="text-sm">Key password</Label>
|
||
<Input
|
||
type="password"
|
||
value={lotw.key_password}
|
||
onChange={(e) => setLotw({ key_password: e.target.value })}
|
||
placeholder="only if your certificate key has a password"
|
||
className="text-xs"
|
||
/>
|
||
<Label className="text-sm">Upload flag</Label>
|
||
<div>
|
||
<Select value={lotw.upload_flag || 'R'} onValueChange={(v) => setLotw({ upload_flag: v })}>
|
||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="N">Upload when LoTW sent = No</SelectItem>
|
||
<SelectItem value="R">Upload when LoTW sent = Requested</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="text-[10px] text-muted-foreground mt-1">
|
||
Must match your default LoTW <em>sent</em> status in Confirmations, or new QSOs won't be picked up.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={lotw.auto_upload}
|
||
onCheckedChange={(c) => setLotw({ auto_upload: !!c, upload_mode: 'on_close' })}
|
||
/>
|
||
Automatic upload on application close
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={lotw.write_log}
|
||
onCheckedChange={(c) => setLotw({ write_log: !!c })}
|
||
/>
|
||
Write TQSL diagnostic log (-t)
|
||
</label>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" onClick={testLotw} disabled={lotwTesting}>
|
||
<UploadCloud className="size-3.5" /> {lotwTesting ? 'Testing…' : 'Test connection'}
|
||
</Button>
|
||
{lotwTest && (
|
||
<span className={cn('text-xs', lotwTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
||
{lotwTest.msg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : extSvcTab === 'pota' ? (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||
Update your QSOs with the park reference from your <strong>pota.app hunter log</strong>.
|
||
Paste your session token: log in at <span className="font-mono">pota.app</span>, open the browser
|
||
DevTools → <strong>Network</strong> tab, click any <span className="font-mono">api.pota.app</span> request,
|
||
and copy the full <strong>Authorization</strong> header value. The token expires after a while — re-copy it if the sync fails.
|
||
</p>
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-start">
|
||
<Label className="text-sm pt-2">Session token</Label>
|
||
<Textarea
|
||
value={potaToken}
|
||
onChange={(e) => setPotaToken(e.target.value)}
|
||
placeholder="eyJ… (Authorization header from pota.app)"
|
||
className="font-mono text-[11px] h-20"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Button onClick={syncPota} disabled={potaBusy || !potaToken.trim()}>
|
||
{potaBusy ? <><Loader2 className="size-4 mr-1.5 animate-spin" /> Syncing…</> : 'Sync hunter log'}
|
||
</Button>
|
||
<span className="text-[11px] text-muted-foreground">Matches by callsign + band within ±5 min. Only fills QSOs without a POTA ref.</span>
|
||
</div>
|
||
{potaResult && (
|
||
<div className={cn('text-xs rounded-md px-3 py-2 border',
|
||
potaResult.ok ? 'border-emerald-300 bg-emerald-50 text-emerald-800' : 'border-destructive/30 bg-destructive/10 text-destructive')}>
|
||
{potaResult.msg}
|
||
</div>
|
||
)}
|
||
<p className="text-[11px] text-muted-foreground">After a sync, rescan the POTA award to see the new references counted.</p>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
||
<Construction className="size-10 opacity-30" />
|
||
<div className="text-sm font-semibold text-foreground/70">
|
||
{TABS.find((t) => t.k === extSvcTab)?.label} — coming soon
|
||
</div>
|
||
<div className="text-xs">This external service isn't wired up yet.</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function DatabasePanel() {
|
||
async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} }
|
||
async function openExisting() {
|
||
try {
|
||
const p = await PickOpenDatabase();
|
||
if (!p) return;
|
||
await OpenDatabase(p);
|
||
await refreshDb();
|
||
setDbMsg(`Database set to:\n${p}\nRestart OpsLog to apply.`);
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function saveCopy() {
|
||
try {
|
||
const p = await PickSaveDatabase();
|
||
if (!p) return;
|
||
await MoveDatabase(p);
|
||
await refreshDb();
|
||
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function createNew() {
|
||
try {
|
||
const p = await PickSaveDatabase();
|
||
if (!p) return;
|
||
await CreateDatabase(p);
|
||
await refreshDb();
|
||
setDbMsg(`New empty logbook created at:\n${p}\nand selected. Restart OpsLog to apply.`);
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function resetDefault() {
|
||
try {
|
||
await ResetDatabaseToDefault();
|
||
await refreshDb();
|
||
setDbMsg('Database reset to the default location. Restart OpsLog to apply.');
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title="Database"
|
||
hint="Your whole log (QSOs, settings, lookup cache) lives in one SQLite file. Keep it wherever you like — another drive or a synced folder (Seafile, Dropbox…) — and back it up automatically."
|
||
/>
|
||
<div className="space-y-4 max-w-2xl">
|
||
<div className="space-y-1">
|
||
<Label>Current database</Label>
|
||
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
||
{dbSettings.path || '—'}
|
||
{dbSettings.is_custom
|
||
? <span className="ml-2 text-[10px] text-emerald-700">(custom location)</span>
|
||
: <span className="ml-2 text-[10px] text-muted-foreground">(default)</span>}
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground">Default: <span className="font-mono">{dbSettings.default_path}</span></div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> New database…</Button>
|
||
<Button variant="outline" size="sm" onClick={openExisting}>Open existing…</Button>
|
||
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy & switch…</Button>
|
||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
||
</div>
|
||
|
||
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
||
<strong>New</strong> creates a fresh empty logbook and switches to it (handy for a separate contest/SOTA log).{' '}
|
||
<strong>Open existing</strong> points OpsLog at a file you already have.{' '}
|
||
<strong>Save a copy</strong> clones the current database elsewhere and switches to it.{' '}
|
||
Any database change takes effect on the next launch.
|
||
</div>
|
||
|
||
{dbMsg && (
|
||
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
|
||
<span>{dbMsg}</span>
|
||
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>Quit now</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Backup settings, merged into this Database section. */}
|
||
<div className="border-t border-border/60 mt-6 pt-5">
|
||
{BackupPanel()}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function AudioPanel() {
|
||
const deviceSelect = (
|
||
field: keyof AudioSettings,
|
||
devices: AudioDev[],
|
||
placeholder: string,
|
||
) => (
|
||
<Select
|
||
value={(audioCfg[field] as string) || '_'}
|
||
onValueChange={(v) => setAudioField({ [field]: v === '_' ? '' : v } as any)}
|
||
>
|
||
<SelectTrigger className="h-8"><SelectValue placeholder={placeholder} /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="_">— none / system default —</SelectItem>
|
||
{devices.map((d) => (
|
||
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' (default)' : ''}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
);
|
||
return (
|
||
<>
|
||
<div className="flex items-start justify-between">
|
||
<SectionHeader
|
||
title="Audio devices & voice keyer"/>
|
||
<Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
|
||
Refresh devices
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-3 max-w-2xl">
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">From Radio (RX in)</Label>
|
||
{deviceSelect('from_radio', audioInputs, 'Rig audio output → soundcard input')}
|
||
<Label className="text-sm">To Radio (TX out)</Label>
|
||
{deviceSelect('to_radio', audioOutputs, 'Soundcard output → rig mic/data in')}
|
||
<Label className="text-sm">Recording mic</Label>
|
||
{deviceSelect('recording_device', audioInputs, 'Your microphone (record DVK messages)')}
|
||
<Label className="text-sm">Listening (preview)</Label>
|
||
{deviceSelect('listening_device', audioOutputs, 'Local speakers for preview')}
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
|
||
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
|
||
<h4 className="text-sm font-semibold text-foreground">QSO recorder</h4>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} />
|
||
Record every QSO to an audio file (From Radio + your mic)
|
||
</label>
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">Recordings folder</Label>
|
||
<div className="flex gap-2">
|
||
<Input value={audioCfg.qso_dir} onChange={(e) => setAudioField({ qso_dir: e.target.value })}
|
||
placeholder="C:\…\OpsLog\Recordings" className="h-8 font-mono text-xs" />
|
||
<Button variant="outline" size="sm" className="h-8 shrink-0"
|
||
onClick={() => PickAudioFolder().then((d) => { if (d) setAudioField({ qso_dir: d }); }).catch(() => {})}>
|
||
Browse…
|
||
</Button>
|
||
</div>
|
||
<Label className="text-sm">Pre-roll (seconds)</Label>
|
||
<Input type="number" min={0} max={60} value={audioCfg.preroll_seconds}
|
||
onChange={(e) => setAudioField({ preroll_seconds: Math.max(0, Math.min(60, parseInt(e.target.value, 10) || 0)) })}
|
||
className="h-8 w-24 font-mono" />
|
||
<Label className="text-sm">File format</Label>
|
||
<Select value={audioCfg.format} onValueChange={(v) => setAudioField({ format: v as any })}>
|
||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="wav">WAV (lossless, larger)</SelectItem>
|
||
<SelectItem value="mp3">MP3 (compressed, small)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Label className="text-sm">From Radio level</Label>
|
||
<div className="flex items-center gap-2">
|
||
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
|
||
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
|
||
</div>
|
||
<Label className="text-sm">Mic level</Label>
|
||
<div className="flex items-center gap-2">
|
||
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
|
||
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">If your voice is louder than the station, lower Mic level.</p>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={emailCfg.auto_send} onCheckedChange={(c) => setEmailField({ auto_send: !!c })} />
|
||
Auto-send the recording to the station by e-mail when I log a QSO
|
||
</label>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
|
||
<h4 className="text-sm font-semibold text-foreground">Voice keyer messages (F1–F6)</h4>
|
||
<div className="rounded-md border border-border/60 p-2.5 space-y-2">
|
||
<div className="grid grid-cols-[120px_1fr] gap-2 items-center">
|
||
<Label className="text-sm">PTT method</Label>
|
||
<div className="flex gap-2 items-center">
|
||
<Select value={audioCfg.ptt_method} onValueChange={(v) => setAudioField({ ptt_method: v as any })}>
|
||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">None (VOX)</SelectItem>
|
||
<SelectItem value="cat">CAT (OmniRig)</SelectItem>
|
||
<SelectItem value="rts">Serial RTS</SelectItem>
|
||
<SelectItem value="dtr">Serial DTR</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
{audioCfg.ptt_method !== 'none' && (
|
||
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT().catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
|
||
Test PTT
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
||
<>
|
||
<Label className="text-sm">PTT COM port</Label>
|
||
<div className="flex gap-2 items-center">
|
||
<Select value={audioCfg.ptt_port || '_'} onValueChange={(v) => setAudioField({ ptt_port: v === '_' ? '' : v })}>
|
||
<SelectTrigger className="h-8 w-44"><SelectValue placeholder="Pick a COM port" /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="_">— select —</SelectItem>
|
||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button variant="ghost" size="sm" className="h-8 text-[11px]"
|
||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||
Refresh
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{dvkErr && <p className="text-[11px] text-destructive">{dvkErr}</p>}
|
||
<div className="space-y-1.5">
|
||
{dvkMsgs.map((m) => {
|
||
const recHere = dvkStat.recording && dvkStat.rec_slot === m.slot;
|
||
const recBusy = dvkStat.recording && !recHere;
|
||
return (
|
||
<div key={m.slot} className="flex items-center gap-2">
|
||
<span className="w-7 font-mono text-xs font-bold text-muted-foreground">F{m.slot}</span>
|
||
<Input
|
||
className="h-8 flex-1"
|
||
placeholder={`Message ${m.slot} label (CQ, report, 73…)`}
|
||
value={m.label}
|
||
onChange={(e) => setDvkMsgs((ms) => ms.map((x) => x.slot === m.slot ? { ...x, label: e.target.value } : x))}
|
||
onBlur={(e) => SetDVKLabel(m.slot, e.target.value).catch(() => {})}
|
||
/>
|
||
<span className="w-16 text-[11px] text-muted-foreground text-right">
|
||
{m.has_audio ? `✓ ${m.duration_sec.toFixed(1)}s` : '—'}
|
||
</span>
|
||
<Button
|
||
type="button"
|
||
variant={recHere ? 'destructive' : 'outline'} size="sm" className="h-8 w-28 shrink-0 select-none touch-none"
|
||
disabled={recBusy}
|
||
onPointerDown={(e) => {
|
||
e.preventDefault();
|
||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||
setDvkErr('');
|
||
DVKStartRecord(m.slot).catch((err) => setDvkErr('Record: ' + String(err?.message ?? err)));
|
||
}}
|
||
onPointerUp={() => {
|
||
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr('Save: ' + String(err?.message ?? err)));
|
||
}}
|
||
>
|
||
{recHere ? '● Recording…' : '● Hold to rec'}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline" size="sm" className="h-8 w-20 shrink-0"
|
||
disabled={!m.has_audio || dvkStat.recording}
|
||
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr('Play: ' + String(err?.message ?? err))))}
|
||
>
|
||
{dvkStat.playing ? '■ Stop' : '▶ Play'}
|
||
</Button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function GeneralPanel() {
|
||
return (
|
||
<>
|
||
<SectionHeader title="General" hint="App behaviour preferences (saved instantly, machine-local)." />
|
||
<div className="space-y-4 max-w-lg">
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={autofocusWB}
|
||
onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); localStorage.setItem('opslog.autofocusWB', v ? '1' : '0'); }}
|
||
className="mt-0.5"
|
||
/>
|
||
<span>
|
||
Auto-focus "Worked before" for stations already worked
|
||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||
When you type a callsign you've contacted before, OpsLog jumps to the Worked before tab. Turn off to stay on your current tab.
|
||
</span>
|
||
</span>
|
||
</label>
|
||
|
||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={clubInfo.enabled}
|
||
onCheckedChange={async (c) => {
|
||
const v = !!c; setClubInfo((s) => ({ ...s, enabled: v })); setClubErr('');
|
||
try {
|
||
await SetClublogCtyEnabled(v);
|
||
let info = (await GetClublogCtyInfo()) as ClubInfo;
|
||
// First enable with no cached data → download it now.
|
||
if (v && !info.loaded) {
|
||
setClubBusy(true);
|
||
try { info = (await DownloadClublogCty()) as ClubInfo; }
|
||
finally { setClubBusy(false); }
|
||
}
|
||
setClubInfo(info);
|
||
} catch (e: any) { setClubErr(String(e?.message ?? e)); }
|
||
}}
|
||
className="mt-0.5"
|
||
/>
|
||
<span>
|
||
Use ClubLog Country File for callsign resolution
|
||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||
Applies ClubLog's date-ranged full-callsign <strong>exceptions</strong> that cty.dat lacks — e.g. VK2/SP9FIH
|
||
resolves to Lord Howe Island (not Australia) for the DXpedition dates. Used on entry, import, UDP, and the
|
||
right-click <em>Update from ClubLog</em>.
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<div className="flex items-center gap-3 pl-6">
|
||
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
|
||
onClick={() => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); }}>
|
||
{clubBusy ? 'Downloading…' : (clubInfo.loaded ? 'Update ClubLog data' : 'Download ClubLog data')}
|
||
</Button>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{clubInfo.loaded
|
||
? `${clubInfo.count.toLocaleString()} exceptions${clubInfo.date ? ' · ' + clubInfo.date.slice(0, 10) : ''}`
|
||
: 'not downloaded'}
|
||
</span>
|
||
</div>
|
||
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function EmailPanel() {
|
||
return (
|
||
<>
|
||
<SectionHeader title="E-mail"/>
|
||
<div className="space-y-3 max-w-2xl">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={emailCfg.enabled} onCheckedChange={(c) => setEmailField({ enabled: !!c })} />
|
||
Enable e-mail sending
|
||
</label>
|
||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||
<Label className="text-sm">SMTP server</Label>
|
||
<Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
|
||
<Label className="text-sm">Port / encryption</Label>
|
||
<div className="flex gap-2 items-center">
|
||
<Input type="number" className="h-8 w-24 font-mono" value={emailCfg.smtp_port} onChange={(e) => setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} />
|
||
<Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
|
||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="starttls">STARTTLS (587)</SelectItem>
|
||
<SelectItem value="ssl">SSL/TLS (465)</SelectItem>
|
||
<SelectItem value="none">None</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div />
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={emailCfg.auth} onCheckedChange={(c) => setEmailField({ auth: !!c })} />
|
||
SMTP requires authorization
|
||
</label>
|
||
<Label className="text-sm">Username</Label>
|
||
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
|
||
<Label className="text-sm">Password</Label>
|
||
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||
<Label className="text-sm">From address</Label>
|
||
<Input className="h-8" placeholder="you@example.com" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" className="h-8"
|
||
onClick={() => { setEmailMsg('Sending test…'); SaveEmailSettings(emailCfg as any).then(() => TestEmail('')).then(() => setEmailMsg('Test e-mail sent ✓')).catch((e: any) => setEmailMsg('Test failed: ' + String(e?.message ?? e))); }}>
|
||
Send test e-mail
|
||
</Button>
|
||
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Map sections to their content + icon (for placeholder).
|
||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||
general: GeneralPanel,
|
||
email: EmailPanel,
|
||
station: StationPanel,
|
||
profiles: ProfilesPanel,
|
||
operating: OperatingPanelWrapper,
|
||
confirmations: ConfirmationsPanel,
|
||
'external-services': ExternalServicesPanel,
|
||
lookup: LookupPanel,
|
||
'lists-bands': BandsPanel,
|
||
'lists-modes': ModesPanel,
|
||
cluster: ClusterPanel,
|
||
udp: UDPIntegrationsPanelWrapper,
|
||
backup: BackupPanel,
|
||
database: DatabasePanel,
|
||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||
cat: CATPanel,
|
||
rotator: RotatorPanel,
|
||
winkeyer: WinkeyerPanel,
|
||
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
||
audio: AudioPanel,
|
||
};
|
||
|
||
return (
|
||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||
<DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||
<DialogHeader>
|
||
<DialogTitle>Preferences</DialogTitle>
|
||
<DialogDescription className="sr-only">Configure OpsLog modules — station, lookup, hardware…</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{loading ? (
|
||
<div className="p-6 text-muted-foreground">Loading…</div>
|
||
) : (
|
||
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
|
||
{/* Left sidebar tree */}
|
||
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
|
||
<Tree selected={selected} onSelect={setSelected} />
|
||
</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 variant="outline" onClick={() => save(false)} disabled={saving || loading}>{saving ? 'Saving…' : 'Save'}</Button>
|
||
<Button onClick={() => save(true)} disabled={saving || loading}>{saving ? 'Saving…' : 'Save and close'}</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>
|
||
);
|
||
}
|