feat: status bar added

This commit is contained in:
2026-05-30 01:35:50 +02:00
parent 8f1ad126ac
commit 806b39970b
24 changed files with 1933 additions and 451 deletions
+97 -7
View File
@@ -65,6 +65,28 @@ const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
};
// Small coloured dot + label used in the band-map legend strip.
function LegendDot({ cls, label }: { cls: string; label: string }) {
return (
<span className="inline-flex items-center gap-1">
<span className={cn('size-2 rounded-full', cls)} />
{label}
</span>
);
}
// Human-readable label for a spot status — used in the pill hover tooltip
// so the operator can see WHY a spot is coloured the way it is.
function statusLabel(s: string): string {
switch (s) {
case 'new': return 'NEW DXCC (entity never worked)';
case 'new-band': return 'NEW BAND (entity not worked on this band)';
case 'new-slot': return 'NEW SLOT (mode not worked on this band)';
case 'worked': return 'Worked (this band + mode already in log)';
default: return 'Entity not resolved';
}
}
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
// pill = full pill background+text+border
// bar = thick left accent inside the pill
@@ -114,6 +136,13 @@ const PILL_GAP = 32; // px between scale border and first pill (room for leade
const LABEL_W = 200;
const TOP_PAD = 14; // px of breathing room above/below the band edges so
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
// Max FT-mode (FT8/FT4/…) pills drawn at once. These pile up on a single
// watering-hole frequency and otherwise spawn hundreds of spots that fan out
// and cover the whole map. ONLY the FT family is capped — CW, SSB and other
// digital spots are always shown in full. When more than this FT spots are in
// band we keep the most useful (new entities first, worked last; ties broken
// by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30;
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
const range = BAND_RANGES[band];
@@ -146,19 +175,53 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
// larger). When more labels stack than fit in the band's natural pixel
// span, totalH grows so scrolling reveals them.
type Placed = { spot: Spot; freqY: number; labelY: number };
const { placed, totalH } = useMemo<{ placed: Placed[]; totalH: number }>(() => {
const { placed, totalH, hidden } = useMemo<{ placed: Placed[]; totalH: number; hidden: number }>(() => {
// innerH is the band's stretched pixel span; total adds top+bottom
// padding so the edge freq labels aren't clipped at y=0 / y=H.
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD };
if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD, hidden: 0 };
const seen = new Set<string>();
const filtered: Spot[] = [];
const inBand: Spot[] = [];
for (const s of spots) {
if (s.freq_khz < lo || s.freq_khz > hi) continue;
if (seen.has(s.dx_call)) continue;
seen.add(s.dx_call);
filtered.push(s);
inBand.push(s);
}
// Only the FT family (FT8/FT4/…) is capped — it's what floods a single
// watering-hole frequency. Everything else (CW, SSB, RTTY, PSK, …) is
// always shown in full.
const isFlood = (s: Spot) => /^FT/.test(inferSpotMode(s.comment ?? '', s.freq_hz));
const ftSpots = inBand.filter(isFlood);
const otherSpots = inBand.filter((s) => !isFlood(s));
// Rank an FT spot by usefulness (new entity → unworked → worked); ties
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
const rank = (s: Spot) => {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
switch (spotStatus[k]?.status ?? '') {
case 'new': return 0;
case 'new-band': return 1;
case 'new-slot': return 2;
case 'worked': return 4;
default: return 3;
}
};
let keptFt = ftSpots;
let hiddenCount = 0;
if (ftSpots.length > MAX_VISIBLE_SPOTS) {
const rigK = currentFreqHz ? currentFreqHz / 1000 : (lo + hi) / 2;
keptFt = [...ftSpots]
.sort((a, b) => {
const r = rank(a) - rank(b);
if (r !== 0) return r;
return Math.abs(a.freq_khz - rigK) - Math.abs(b.freq_khz - rigK);
})
.slice(0, MAX_VISIBLE_SPOTS);
hiddenCount = ftSpots.length - keptFt.length;
}
const filtered = [...otherSpots, ...keptFt];
filtered.sort((a, b) => b.freq_khz - a.freq_khz);
// Desired pill-CENTRE Y for each spot = its true frequency's Y.
@@ -206,14 +269,32 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
return {
placed: out,
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
hidden: hiddenCount,
};
}, [spots, range, lo, hi, span, pxPerKHz, containerH]);
}, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz]);
// freqToY for elements rendered outside the memo (ticks, rig pointer).
// Must mirror the same offset so the rig triangle sits on the right kHz.
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
// Auto-centre on the rig frequency when the map opens or the band changes
// (once per band, so it doesn't fight the user's manual scrolling). Waits
// for the scroller height to be measured and a valid in-band rig freq.
const centeredForRef = useRef<string>('');
useEffect(() => {
if (!range || containerH <= 0 || currentFreqHz <= 0) return;
const kHz = currentFreqHz / 1000;
if (kHz < lo || kHz > hi) return;
if (centeredForRef.current === band) return;
const el = scrollerRef.current;
if (!el) return;
centeredForRef.current = band;
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
// freqToY is recomputed each render; intentionally excluded from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [band, containerH, currentFreqHz, range, lo, hi]);
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
@@ -375,7 +456,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
{/* Pills absolutely positioned at their (anti-overlapped) Y */}
{placed.map((p, i) => {
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
const st = spotStatus[k]?.status ?? '';
const entry = spotStatus[k];
const st = entry?.status ?? '';
const style = statusStyle(st);
const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
return (
@@ -389,7 +471,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
'hover:translate-x-0.5 hover:shadow',
style.pill,
)}
title={`${p.spot.dx_call} · ${p.spot.freq_khz.toFixed(1)} kHz${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
>
{/* Status accent strip on the left */}
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
@@ -406,8 +488,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
})}
</div>
</div>
{/* Colour legend — what each pill colour means. */}
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
<LegendDot cls="bg-rose-400" label="New DXCC" />
<LegendDot cls="bg-amber-400" label="New band" />
<LegendDot cls="bg-yellow-300" label="New slot (mode)" />
<LegendDot cls="bg-muted-foreground/30" label="Worked" />
</div>
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
scroll · ctrl+wheel = zoom · = jump to rig
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)</span>}
</div>
</div>
);
+24 -16
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { ChevronUp, Construction } from 'lucide-react';
import { Construction } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
@@ -8,6 +8,7 @@ import {
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { pathBetween } from '@/lib/maidenhead';
import { BandSlotGrid } from '@/components/BandSlotGrid';
export interface DetailsState {
state: string;
@@ -45,9 +46,16 @@ interface Props {
remoteGrid: string; // entry-strip Grid value — destination
details: DetailsState;
onChange: (patch: Partial<DetailsState>) => void;
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
wb?: any;
wbBusy?: boolean;
band: string;
mode: string;
imageUrl?: string;
onOpenImage?: () => void;
}
type TabName = 'info' | 'awards' | 'my' | 'extended';
type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
@@ -67,8 +75,8 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
);
}
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange }: Props) {
const [open, setOpen] = useState<TabName | null>(null);
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode }: Props) {
const [open, setOpen] = useState<TabName>('stats');
// Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes.
@@ -79,7 +87,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
const fmtDeg = (n: number) => `${Math.round(n)}°`;
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
function toggle(t: TabName) { setOpen((prev) => (prev === t ? null : t)); }
function toggle(t: TabName) { setOpen(t); }
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
function setSatellite(on: boolean) {
@@ -94,6 +102,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
}
const tabs: { key: TabName; label: string }[] = [
{ key: 'stats', label: 'Stats (F1)' },
{ key: 'info', label: 'Info (F2)' },
{ key: 'awards', label: 'Awards (F3)' },
{ key: 'my', label: 'My (F4)' },
@@ -101,8 +110,8 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
];
return (
<section className="border-b border-border bg-card shrink-0">
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border">
<section className="border border-border rounded-lg bg-card flex flex-col flex-1 min-h-0 overflow-hidden">
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
{tabs.map((t) => (
<button
key={t.key}
@@ -117,17 +126,15 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
{t.label}
</button>
))}
{open && (
<button
onClick={() => setOpen(null)}
className="ml-auto text-muted-foreground hover:text-foreground p-1.5"
title="Close"
>
<ChevronUp className="size-3.5" />
</button>
)}
</nav>
<div className="overflow-y-auto min-h-0">
{open === 'stats' && (
<div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} />
</div>
)}
{open === 'info' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label="State / pref">
@@ -251,6 +258,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
</Field>
</div>
)}
</div>
</section>
);
}
+173 -204
View File
@@ -1,8 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { UploadCloud, DownloadCloud, Search, Loader2 } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
@@ -28,7 +25,6 @@ const SERVICES = [
{ v: 'lotw', label: 'LoTW' },
];
// Sent-status filter values. Empty string = blank/none.
const SENT_STATUSES = [
{ v: 'R', label: 'Requested' },
{ v: 'N', label: 'No' },
@@ -46,7 +42,9 @@ function fmtDate(iso: string): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
// and download confirmations, while the rest of the app stays usable.
export function QSLManagerPanel() {
const [service, setService] = useState('lotw');
const [sent, setSent] = useState('R');
const [rows, setRows] = useState<UploadRow[]>([]);
@@ -55,22 +53,20 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false);
// 'upload' shows the Select-required search results; 'confirmations' shows
// the rows returned by a Download.
const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
const [confFilter, setConfFilter] = useState('all'); // all | new | dxcc | band | slot
const [logOpen, setLogOpen] = useState(false);
const [logTitle, setLogTitle] = useState('');
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
const [showLog, setShowLog] = useState(false);
const [logLines, setLogLines] = useState<string[]>([]);
const [uploadDone, setUploadDone] = useState(false);
const [busy, setBusy] = useState(false);
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
useEffect(() => {
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
const offDone = EventsOn('qslmgr:done', (d: any) => {
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]);
setUploadDone(true);
setBusy(false);
});
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
setConfirmations((list ?? []) as Confirmation[]);
@@ -81,16 +77,28 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
const selectedCount = selected.size;
const allSelected = rows.length > 0 && selected.size === rows.length;
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
async function selectRequired() {
const shownConfs = useMemo(() => confirmations.filter((c) => {
switch (confFilter) {
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
case 'dxcc': return c.new_dxcc;
case 'band': return c.new_band;
case 'slot': return c.new_slot;
default: return true;
}
}), [confirmations, confFilter]);
const selectRequired = useCallback(async () => {
setSearching(true);
setError('');
try {
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
const list = (r ?? []) as UploadRow[];
setRows(list);
setSelected(new Set(list.map((x) => x.id))); // auto-select all found
setSelected(new Set(list.map((x) => x.id)));
setViewMode('upload');
setShowLog(false);
} catch (e: any) {
setError(String(e?.message ?? e));
setRows([]);
@@ -98,7 +106,7 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
} finally {
setSearching(false);
}
}
}, [service, sent]);
function toggle(id: number) {
setSelected((s) => {
@@ -114,209 +122,170 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
async function upload() {
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
if (ids.length === 0) return;
setLogLines([]);
setUploadDone(false);
setLogAction('upload');
setLogTitle('Uploading to ' + serviceLabel);
setLogOpen(true);
try {
await UploadQSOsManual(service, ids);
} catch (e: any) {
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
setUploadDone(true);
}
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadQSOsManual(service, ids); }
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
}
async function download() {
setLogLines([]);
setUploadDone(false);
setLogAction('download');
setLogTitle('Downloading confirmations from ' + serviceLabel);
setLogOpen(true);
try {
await DownloadConfirmations(service, addNotFound);
} catch (e: any) {
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
setUploadDone(true);
}
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
try { await DownloadConfirmations(service, addNotFound); }
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
}
function closeLog() {
setLogOpen(false);
// After an upload, refresh the search so uploaded QSOs drop out of the
// filter. After a download, leave the confirmations list on screen.
function viewResults() {
setShowLog(false);
if (logAction === 'upload') selectRequired();
}
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
return (
<>
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-[1000px] w-full max-h-[88vh] grid grid-rows-[auto_auto_1fr_auto] gap-0 p-0">
<DialogHeader className="px-4 pt-4">
<DialogTitle>QSL Manager</DialogTitle>
<DialogDescription className="sr-only">Upload logged QSOs to online logbooks.</DialogDescription>
</DialogHeader>
{/* Search toolbar */}
<div className="flex items-end gap-3 px-4 py-3 border-b border-border bg-muted/20">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
<Select value={service} onValueChange={setService}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>
{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
<Select value={sent} onValueChange={setSent}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>
{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching}>
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
Select required
</Button>
<div className="flex-1" />
<span className="text-xs text-muted-foreground">
{viewMode === 'confirmations'
? `${confirmations.length} confirmation(s) received`
: `${rows.length} found · ${selectedCount} selected`}
</span>
<div className="flex flex-col min-h-0 flex-1">
{/* Search toolbar */}
<div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
<Select value={service} onValueChange={setService}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
<Select value={sent} onValueChange={setSent}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
Select required
</Button>
<div className="flex-1" />
{!showLog && viewMode === 'confirmations' && (
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
<Select value={confFilter} onValueChange={setConfFilter}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="new">New (any)</SelectItem>
<SelectItem value="dxcc">New DXCC</SelectItem>
<SelectItem value="band">New band</SelectItem>
<SelectItem value="slot">New slot</SelectItem>
</SelectContent>
</Select>
</div>
)}
{logLines.length > 0 && (
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
{showLog ? <><ListChecks className="size-3.5" /> Results</> : <><ScrollText className="size-3.5" /> Log</>}
</Button>
)}
<span className="text-xs text-muted-foreground">
{viewMode === 'confirmations'
? `${shownConfs.length} / ${confirmations.length} confirmation(s)`
: `${rows.length} found · ${selectedCount} selected`}
</span>
</div>
{/* Results grid */}
<div className="overflow-auto px-4 py-2 min-h-[200px]">
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
{/* Content: log OR results grid */}
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
{viewMode === 'confirmations' ? (
confirmations.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">No new confirmations.</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2">Date UTC</th>
<th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th>
<th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">Country</th>
<th className="py-1.5 px-2">New?</th>
</tr>
</thead>
<tbody>
{confirmations.map((c, i) => (
<tr key={i} className="border-b border-border/40">
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
<td className="py-1 px-2">{c.band}</td>
<td className="py-1 px-2">{c.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
<td className="py-1 px-2">
{c.new_dxcc ? (
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
) : c.new_band ? (
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
) : c.new_slot ? (
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
) : (
<span className="text-muted-foreground/50"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
)
) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">
Pick a service + sent status, then Select required.
</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<th className="py-1.5 px-2">Date UTC</th>
<th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th>
<th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">Country</th>
<th className="py-1.5 px-2">Sent</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
onClick={() => toggle(r.id)}
>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
</td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DialogFooter className="px-4 py-3 border-t border-border sm:justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={download} title="Fetch confirmations from the service and update received status">
<DownloadCloud className="size-3.5" />
Download confirmations
</Button>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
Add not-found
</label>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
<Button size="sm" onClick={upload} disabled={selectedCount === 0}>
<UploadCloud className="size-3.5" />
Upload {selectedCount} to {serviceLabel}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Upload progress / log window */}
<Dialog open={logOpen} onOpenChange={(o) => { if (!o && uploadDone) closeLog(); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{logTitle || 'Working…'}</DialogTitle>
<DialogDescription className="sr-only">Progress log.</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] overflow-auto rounded-md border border-border bg-muted/30 p-2.5 font-mono text-[11px] space-y-0.5">
{showLog ? (
<div className="font-mono text-[11px] space-y-0.5 py-1">
{logLines.length === 0 ? (
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting</div>
) : logLines.map((l, i) => (
<div key={i} className={cn(l.includes('FAILED') || l.includes('failed') ? 'text-rose-700' : l.includes('OK') ? 'text-emerald-700' : 'text-foreground')}>{l}</div>
<div key={i} className={cn(
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
: 'text-foreground/90')}>{l}</div>
))}
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> working</div>}
</div>
<DialogFooter>
<Button size="sm" onClick={closeLog} disabled={!uploadDone}>
{uploadDone ? 'Close' : <><Loader2 className="size-3.5 animate-spin" /> Uploading</>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
) : viewMode === 'confirmations' ? (
shownConfs.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">
{confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">New?</th>
</tr>
</thead>
<tbody>
{shownConfs.map((c, i) => (
<tr key={i} className="border-b border-border/40">
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
<td className="py-1 px-2">{c.band}</td>
<td className="py-1 px-2">{c.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
<td className="py-1 px-2">
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
: <span className="text-muted-foreground/50"></span>}
</td>
</tr>
))}
</tbody>
</table>
)
) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">Sent</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
onClick={() => toggle(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
</td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Action bar */}
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={download} disabled={busy}
title="Fetch confirmations from the service and update received status">
<DownloadCloud className="size-3.5" /> Download confirmations
</Button>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
Add not-found
</label>
</div>
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
</Button>
</div>
</div>
);
}
+50 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Trash2 } from 'lucide-react';
import { Trash2, Search, Loader2 } from 'lucide-react';
import { LookupCallsign } from '../../wailsjs/go/main/App';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
@@ -104,11 +105,47 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
const [localErr, setLocalErr] = useState('');
const [saving, setSaving] = useState(false);
const [looking, setLooking] = useState(false);
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value }));
}
// Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into
// the draft — handy after correcting the callsign. Only overwrites the
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
async function fetchLookup() {
const call = (draft.callsign ?? '').trim().toUpperCase();
if (!call) { setLocalErr('Callsign required'); return; }
setLooking(true);
setLocalErr('');
try {
const r: any = await LookupCallsign(call);
setDraft((d) => ({
...d,
name: r.name ?? d.name,
qth: r.qth ?? d.qth,
address: r.address ?? (d as any).address,
email: r.email ?? (d as any).email,
country: r.country ?? d.country,
grid: r.grid ?? d.grid,
state: r.state ?? d.state,
cnty: r.cnty ?? d.cnty,
cont: r.cont ?? d.cont,
qsl_via: r.qsl_via ?? d.qsl_via,
dxcc: r.dxcc || d.dxcc,
cqz: r.cqz || d.cqz,
ituz: r.ituz || d.ituz,
lat: r.lat || d.lat,
lon: r.lon || d.lon,
}));
} catch (e: any) {
setLocalErr('Lookup: ' + String(e?.message ?? e));
} finally {
setLooking(false);
}
}
function save() {
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
setSaving(true);
@@ -200,8 +237,15 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
<TabsContent value="basic" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label="Callsign" span={6}>
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11"
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
<div className="flex gap-2">
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11 flex-1"
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
<Button type="button" variant="outline" className="h-11" onClick={fetchLookup} disabled={looking}
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, QTH, country, grid, zones…">
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />}
Fetch
</Button>
</div>
</F>
<F label="Start (UTC)" span={3}><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></F>
<F label="End (UTC)" span={3}><Input type="datetime-local" value={dateOff} onChange={(e) => setDateOff(e.target.value)} /></F>
@@ -232,6 +276,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
<F label="RST sent"><Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} /></F>
<F label="RST rcvd"><Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} /></F>
<F label="TX power (W)"><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
</div>
</TabsContent>
@@ -142,6 +142,7 @@ const COL_CATALOG: ColEntry[] = [
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 },
{ group: 'Uploads', label: 'QRZ.com upload date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com date', field: 'qrzcom_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: 'QRZ.com upload status', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com status', field: 'qrzcom_qso_upload_status' as any, width: 110 },
{ group: 'Uploads', label: 'QRZ.com confirmed', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com cfm', field: 'qrzcom_qso_download_status' as any, width: 100 },
// ── Contest ──
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from 'react';
import { Satellite, Loader2 } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
export interface RecentSpotQSO {
callsign: string;
freqKHz: number;
mode: string;
band?: string;
}
interface Props {
open: boolean;
onClose: () => void;
// Pre-fill values: callsign from the QSO entry (or last logged), the
// current TX freq in kHz, and the current mode (goes into the comment).
defaultCall: string;
defaultFreqKHz: number;
defaultMode: string;
// Master cluster name, shown so the user knows where the spot goes.
targetName?: string;
recent: RecentSpotQSO[];
onSend: (call: string, freqKHz: number, comment: string) => Promise<void>;
}
// SendSpotModal — Log4OM-style "Send Spot" window. Announces a DX spot on
// the master cluster: callsign + frequency (kHz) + a free message (defaults
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
// contact into the form.
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
const [call, setCall] = useState('');
const [freqKHz, setFreqKHz] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [ok, setOk] = useState(false);
const callRef = useRef<HTMLInputElement>(null);
// (Re)initialise the form each time the dialog opens.
useEffect(() => {
if (!open) return;
setCall((defaultCall || '').toUpperCase());
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
setMessage(defaultMode || '');
setError('');
setOk(false);
// Focus the freq if the call is already known, else the call.
setTimeout(() => callRef.current?.focus(), 50);
}, [open, defaultCall, defaultFreqKHz, defaultMode]);
async function send() {
const c = call.trim().toUpperCase();
const f = parseFloat(freqKHz);
if (!c) { setError('Callsign required'); return; }
if (!f || f <= 0) { setError('Frequency (kHz) required'); return; }
setBusy(true);
setError('');
try {
await onSend(c, f, message.trim());
setOk(true);
// Brief success flash, then close.
setTimeout(() => { setOk(false); onClose(); }, 700);
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setBusy(false);
}
}
function pick(q: RecentSpotQSO) {
setCall(q.callsign.toUpperCase());
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
if (q.mode) setMessage(q.mode);
setError('');
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Satellite className="size-4 text-primary" /> Send DX Spot
</DialogTitle>
</DialogHeader>
<div className="px-5 py-3 space-y-3">
<div className="flex gap-3">
<div className="flex flex-col flex-1">
<Label className="mb-1">Callsign</Label>
<Input
ref={callRef}
className="font-mono uppercase font-bold"
value={call}
onChange={(e) => setCall(e.target.value.toUpperCase())}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="DX call"
/>
</div>
<div className="flex flex-col w-32">
<Label className="mb-1">Frequency (kHz)</Label>
<Input
className="font-mono"
value={freqKHz}
onChange={(e) => setFreqKHz(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="14205"
/>
</div>
</div>
<div className="flex flex-col">
<Label className="mb-1">Message</Label>
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="e.g. CW · TNX QSO"
/>
</div>
{recent.length > 0 && (
<div>
<Label className="mb-1 block">Latest QSOs</Label>
<div className="max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border/60">
{recent.map((q, i) => (
<button
key={`${q.callsign}-${i}`}
type="button"
onClick={() => pick(q)}
className="flex w-full items-center gap-2 px-2 py-1 text-left text-xs hover:bg-accent/40"
>
<span className="font-mono font-bold w-24 truncate">{q.callsign}</span>
<span className="font-mono text-muted-foreground w-20 text-right">{q.freqKHz > 0 ? trimKHz(q.freqKHz) : '—'}</span>
<span className="text-muted-foreground">{q.mode || ''}</span>
</button>
))}
</div>
</div>
)}
{error && <div className="text-xs text-rose-600">{error}</div>}
</div>
<DialogFooter>
<span className="text-[11px] text-muted-foreground mr-auto self-center">
{ok ? 'Spot sent ✓' : targetName ? `${targetName}` : 'Master cluster'}
</span>
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
<Button onClick={send} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Satellite className="size-3.5" />}
{busy ? 'Sending…' : 'Send spot'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// trimKHz formats a kHz value without a trailing ".0" (14205) but keeps
// sub-kHz precision when present (10138.7).
function trimKHz(khz: number): string {
return String(Math.round(khz * 10) / 10).replace(/\.0$/, '');
}
+114 -2
View File
@@ -16,6 +16,7 @@ import {
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp,
GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
TestLoTWUpload, ListTQSLStationLocations,
@@ -141,6 +142,7 @@ type SectionId =
| 'lists-modes'
| 'cluster'
| 'backup'
| 'database'
| 'awards'
| 'cat'
| 'rotator'
@@ -171,6 +173,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
{ kind: 'item', label: 'Database backup', id: 'backup' },
{ kind: 'item', label: 'Database location', id: 'database' },
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
],
},
@@ -196,6 +199,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
'lists-modes': 'Modes & default RST',
cluster: 'DX Cluster',
backup: 'Database backup',
database: 'Database location',
udp: 'UDP integrations',
awards: 'Awards',
cat: 'CAT interface',
@@ -319,7 +323,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [activeProfile, setActiveProfile] = useState<Profile | null>(null);
const updateActive = (patch: Partial<Profile>) =>
setActiveProfile((p) => (p ? { ...p, ...patch } : p));
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [] });
const [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('');
@@ -384,6 +390,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
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[]>([]);
@@ -457,6 +466,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
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);
@@ -464,6 +478,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
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));
@@ -600,7 +615,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
default_rst_rcvd: (m.default_rst_rcvd ?? '').trim(),
}))
.filter((m) => m.name !== '');
await SaveListsSettings({ bands, modes } as any);
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({
@@ -1233,6 +1254,28 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</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>
</>
);
}
@@ -2121,6 +2164,74 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
);
}
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 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 location"
hint="Keep your log database wherever you like — another drive or a synced folder (Seafile, Dropbox…) — so it survives a Windows reinstall. Everything (QSOs, settings, lookup cache) lives in this one file."
/>
<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 variant="outline" size="sm" onClick={openExisting}>Open existing database</Button>
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy &amp; switch to it</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>Open existing</strong> points OpsLog at a database file you already have (e.g. after reinstalling Windows).{' '}
<strong>Save a copy</strong> writes the current database to a new place and switches to it.{' '}
A 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>
</>
);
}
// Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = {
station: StationPanel,
@@ -2134,6 +2245,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
cluster: ClusterPanel,
udp: UDPIntegrationsPanelWrapper,
backup: BackupPanel,
database: DatabasePanel,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
rotator: RotatorPanel,
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from 'react';
import { Input } from './input';
import { cn } from '@/lib/utils';
// Searchable combobox: type to filter, click/Enter to pick. On blur it commits
// only an exact (case-insensitive) match — otherwise it reverts, so the field
// can't hold a typo'd value that isn't in the list.
export function Combobox({
value, onChange, options, placeholder, className, allowFreeText = false,
}: {
value: string;
onChange: (v: string) => void;
options: string[];
placeholder?: string;
className?: string;
allowFreeText?: boolean;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, []);
const filtered = open
? options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60)
: [];
function commit(v: string) {
onChange(v);
setQuery(v);
setOpen(false);
}
function onBlur() {
// Defer so a click on an option registers first.
setTimeout(() => {
setOpen(false);
const exact = options.find((o) => o.toLowerCase() === query.trim().toLowerCase());
if (exact) { onChange(exact); setQuery(exact); }
else if (allowFreeText) { onChange(query.trim()); }
else { setQuery(value); } // revert typo
}, 120);
}
return (
<div ref={ref} className={cn('relative', className)}>
<Input
value={open ? query : value}
placeholder={placeholder}
// Focus selects the text so a keystroke replaces it — but does NOT
// open the list (so tabbing in doesn't pop the dropdown).
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onBlur={onBlur}
onKeyDown={(e) => {
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); }
else if (e.key === 'Escape') { setQuery(value); setOpen(false); }
// Tab: just let it move on; onBlur commits/closes. Options are
// tabIndex=-1 so a single Tab leaves the field.
}}
/>
{open && filtered.length > 0 && (
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
{filtered.map((o) => (
<button
key={o}
type="button"
tabIndex={-1}
className="block w-full text-left px-2 py-1 hover:bg-accent/40"
onMouseDown={(e) => { e.preventDefault(); commit(o); }}
>
{o}
</button>
))}
</div>
)}
</div>
);
}
+3 -3
View File
@@ -25,10 +25,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean }
>(({ className, children, hideClose, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean }
>(({ className, children, hideClose, hideOverlay, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
{!hideOverlay && <DialogOverlay />}
<DialogPrimitive.Content
ref={ref}
className={cn(