rigs completed

This commit is contained in:
2026-05-28 18:35:22 +02:00
parent d3c9982c66
commit e8cac569e3
26 changed files with 3834 additions and 391 deletions
+57
View File
@@ -19,6 +19,8 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"ag-grid-community": "^35.3.0",
"ag-grid-react": "^35.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
@@ -2641,6 +2643,35 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/ag-charts-types": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-13.3.0.tgz",
"integrity": "sha512-UMoAn908LC4ZIJSNfUckSBEFa79Mi1vFRA8qIRx+NusEuuFgXDioCZx4MxM7O3rDXlxTWH9DvQmcDjh7vyd89w==",
"license": "MIT"
},
"node_modules/ag-grid-community": {
"version": "35.3.0",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-35.3.0.tgz",
"integrity": "sha512-c9WQWB88J965IjBC/GPUX30aAZix10o6oYT86DWipcxgLZTIQlLSilJJEr1bno/245rPEAIMjhoU1gp9VIfURg==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "13.3.0"
}
},
"node_modules/ag-grid-react": {
"version": "35.3.0",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-35.3.0.tgz",
"integrity": "sha512-3c6YEFGQGNZxEi1PdK0b+WhKkKRJ7KxuYzsG4UmISyax5/J7N93f8B1TZK1pq+AgzPhdk/++vjZe3KhFdF3tog==",
"license": "MIT",
"dependencies": {
"ag-grid-community": "35.3.0",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -3302,6 +3333,15 @@
"node": ">=18"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3351,6 +3391,17 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -3376,6 +3427,12 @@
"react": "^18.3.1"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+2
View File
@@ -20,6 +20,8 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"ag-grid-community": "^35.3.0",
"ag-grid-react": "^35.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
+1 -1
View File
@@ -1 +1 @@
58f02c99f9fceb8f5aeae2c8b90fd325
687705a933fcf09f20bdb5083955a417
+170 -274
View File
@@ -19,8 +19,9 @@ import {
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses,
GetCATSettings,
OperatingDefaultForBand,
} from '../wailsjs/go/main/App';
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime';
import { EventsOn } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -30,8 +31,11 @@ import { SettingsModal } from '@/components/SettingsModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandSlotGrid } from '@/components/BandSlotGrid';
import { BandMap } from '@/components/BandMap';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { CallHistoryPanel } from '@/components/CallHistoryPanel';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { Button } from '@/components/ui/button';
@@ -327,6 +331,25 @@ export default function App() {
const updateDetails = useCallback((patch: Partial<DetailsState>) => {
setDetails((d) => ({ ...d, ...patch }));
}, []);
// Auto-fill MY_RIG / MY_ANTENNA from the operating conditions tree
// whenever the band changes. The backend resolves the "default antenna
// for this band" within the active profile and returns the (rig,
// antenna) tuple. Empty result → we DO clear the fields so leftover
// values from a previous band don't get logged against the wrong gear.
useEffect(() => {
if (!band) return;
let cancelled = false;
OperatingDefaultForBand(band).then((d) => {
if (cancelled) return;
setDetails((cur) => ({
...cur,
my_rig: d?.station_name || '',
my_antenna: d?.antenna_name || '',
tx_pwr: d?.tx_pwr ?? cur.tx_pwr,
}));
}).catch(() => {});
return () => { cancelled = true; };
}, [band]);
const prefix = useMemo(() => computePrefix(callsign), [callsign]);
// Bearing/distance from operator's home grid to the remote station —
// shown live in the entry strip (SP azimuth) and Info tab (LP + dist).
@@ -340,6 +363,16 @@ export default function App() {
const [filterBand, setFilterBand] = useState('');
const [filterMode, setFilterMode] = useState('');
const [activeTab, setActiveTab] = useState('recent');
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
// huge logs render OK once loaded, but a 25k+ logbook still takes a
// couple of seconds to round-trip from SQLite at launch. Defaulting
// to 500 keeps the first paint instant; the user can bump to "All"
// when they actually want to search history.
const [qsoLimit, setQsoLimit] = useState<number>(() => {
const raw = Number(localStorage.getItem('hamlog.qsoLimit') ?? '500');
return Number.isFinite(raw) && raw > 0 ? raw : 500;
});
useEffect(() => { localStorage.setItem('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
// === DX Cluster live state ===
type ClusterSpot = {
@@ -355,6 +388,11 @@ export default function App() {
time_utc?: string;
country?: string;
continent?: string;
cqz?: number;
ituz?: number;
distance_km?: number;
sp_deg?: number;
lp_deg?: number;
received_at: string;
raw: string;
};
@@ -394,7 +432,7 @@ export default function App() {
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
// different slots don't share the same colour.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string }>>({});
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean }>>({});
// === Modals ===
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
@@ -451,7 +489,7 @@ export default function App() {
try {
const list = await ListQSO({
callsign: filterCallsign, band: filterBand, mode: filterMode,
limit: 500, offset: 0,
limit: qsoLimit, offset: 0,
} as any);
const n = await CountQSO();
setQsos(list);
@@ -460,7 +498,7 @@ export default function App() {
} catch (e: any) {
setError(String(e?.message ?? e));
}
}, [filterCallsign, filterBand, filterMode]);
}, [filterCallsign, filterBand, filterMode, qsoLimit]);
const loadStation = useCallback(async () => {
try { setStation(await GetStationSettings()); } catch {}
@@ -517,7 +555,7 @@ export default function App() {
// CAT live updates. Push freq/band/mode into the entry strip when the rig
// moves, unless the user just typed something (1.5s grace window).
useEffect(() => {
EventsOn('cat:state', (s: CATState) => {
const unsub = EventsOn('cat:state', (s: CATState) => {
setCatState(s);
if (!s?.connected) return;
if (Date.now() < catFreezeUntilRef.current) return;
@@ -554,7 +592,7 @@ export default function App() {
}
}
});
return () => { EventsOff('cat:state'); };
return () => { unsub?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -574,7 +612,7 @@ export default function App() {
// cluster:state fires on connect/disconnect/save/delete — refresh
// the saved-server list too so the source dropdown stays in sync
// when the user adds, deletes or toggles a row in Settings.
EventsOn('cluster:state', async (sts: ServerStatus[]) => {
const unsubState = EventsOn('cluster:state', async (sts: ServerStatus[]) => {
setClusterServerStatuses(sts ?? []);
try {
const list = await ListClusterServers();
@@ -589,13 +627,13 @@ export default function App() {
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
});
EventsOn('cluster:spot', (sp: ClusterSpot) => {
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
setSpots((arr) => {
const next = [sp, ...arr];
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
});
});
return () => { EventsOff('cluster:state'); EventsOff('cluster:spot'); };
return () => { unsubState?.(); unsubSpot?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -623,7 +661,12 @@ export default function App() {
const next = { ...prev };
for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { status: r.status ?? '', country: r.country, continent: (r as any).continent };
next[k] = {
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
};
}
return next;
});
@@ -955,6 +998,7 @@ export default function App() {
return (
<div className="flex flex-col h-screen bg-background">
<ShutdownProgress />
{/* ===== TOPBAR ===== */}
{compact ? (
// Minimal compact topbar — brand + freq + toggle. Saves vertical space
@@ -1127,10 +1171,6 @@ export default function App() {
<Settings className="size-3.5" /> Set station
</Button>
)}
<div className="text-right">
<div className="font-bold text-[15px] leading-none">{total.toLocaleString('en-US')}</div>
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">Total QSOs</div>
</div>
<Button
variant={showBandMap ? 'default' : 'outline'}
size="sm"
@@ -1431,9 +1471,9 @@ export default function App() {
/>
{/* ===== LOWER: tabs+table | call history | (optional) band map ===== */}
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0', showBandMap ? 'grid-cols-[1fr_360px_260px]' : 'grid-cols-[1fr_360px]')}>
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]', showBandMap ? 'grid-cols-[1fr_260px]' : 'grid-cols-[1fr]')}>
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
<TabsList className="px-3 shrink-0">
<TabsTrigger value="main">Main</TabsTrigger>
<TabsTrigger value="recent">
@@ -1441,6 +1481,12 @@ export default function App() {
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{qsos.length}</Badge>
</TabsTrigger>
<TabsTrigger value="cluster">Cluster</TabsTrigger>
<TabsTrigger value="worked">
Worked before
{wb && wb.count > 0 && (
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{wb.count}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="awards">Awards</TabsTrigger>
<TabsTrigger value="propagation">Propagation</TabsTrigger>
</TabsList>
@@ -1515,67 +1561,45 @@ export default function App() {
</div>
)}
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse text-[12.5px]">
<thead>
<tr>
{['Date UTC','Callsign','Band','Mode','MHz','RST sent','RST rcvd','Name','QTH','Country','Grid','Station','Comment',''].map((h, i) => (
<th
key={i}
className={cn(
'sticky top-0 z-10 bg-stone-200 px-2.5 py-2 text-left font-semibold text-muted-foreground text-[11px] uppercase tracking-wide border-b border-border whitespace-nowrap',
h === 'MHz' && 'text-right',
i === 13 && 'w-0',
)}
>{h}</th>
))}
</tr>
</thead>
<tbody>
{qsos.length === 0 ? (
<tr><td colSpan={14} className="text-center py-10 text-muted-foreground italic">No QSO yet. Log your first contact above.</td></tr>
) : qsos.map((q, i) => (
<tr
key={q.id}
className={cn(
'cursor-pointer hover:bg-stone-100 transition-colors',
i % 2 === 1 && 'bg-stone-50/60',
selectedId === q.id && '!bg-accent',
)}
onClick={() => setSelectedId(q.id)}
onDoubleClick={() => openEdit(q.id)}
>
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{fmtDateUTC(q.qso_date)}</td>
<td className="px-2.5 py-1.5 font-mono font-semibold text-primary whitespace-nowrap border-b border-border/40">{q.callsign}</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">
<span className="inline-block px-2 py-0.5 rounded font-mono text-[11px] font-semibold bg-accent text-accent-foreground">{q.band}</span>
</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">
<span className="inline-block px-2 py-0.5 rounded font-mono text-[11px] font-semibold bg-emerald-100 text-emerald-700">{q.mode}</span>
</td>
<td className="px-2.5 py-1.5 font-mono text-right whitespace-nowrap border-b border-border/40">{q.freq_hz ? fmtFreqDots(fmtFreq(q.freq_hz)) : ''}</td>
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.rst_sent ?? ''}</td>
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.rst_rcvd ?? ''}</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">{q.name ?? ''}</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">{q.qth ?? ''}</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">{q.country ?? ''}</td>
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.grid ?? ''}</td>
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.station_callsign ?? ''}</td>
<td className="px-2.5 py-1.5 text-muted-foreground whitespace-nowrap border-b border-border/40 max-w-[200px] overflow-hidden text-ellipsis">{q.comment ?? ''}</td>
<td className="px-1.5 py-0.5 text-right whitespace-nowrap border-b border-border/40 opacity-0 group-hover:opacity-100">
<Button size="icon" variant="ghost" className="size-6 mx-0.5"
onClick={(e) => { e.stopPropagation(); openEdit(q.id); }}>
<Pencil className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6 mx-0.5 hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => { e.stopPropagation(); askDelete(q.id); }}>
<Trash2 className="size-3" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
<RecentQSOsGrid
rows={qsos as any}
total={total}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
onRowSelected={(id) => setSelectedId(id)}
/>
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
<span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{total}</span>
{filterCallsign || filterBand || filterMode ? ' (filtered)' : ''}
</span>
<div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && (
<span className="text-amber-700">Limit reached raise Max to see more.</span>
)}
<Label className="text-[11px] text-muted-foreground">Max</Label>
<Input
type="number"
min={1}
step={100}
className="w-24 h-7 font-mono text-xs"
value={qsoLimit}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n) && n > 0) setQsoLimit(Math.floor(n));
}}
/>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => setQsoLimit(Math.max(total, 1))}
title="Load the entire log"
disabled={total === 0}
>
All ({total})
</Button>
</div>
</div>
</TabsContent>
@@ -1737,205 +1761,74 @@ export default function App() {
</Select>
</div>
<div className="flex-1 overflow-auto">
{(() => {
// Apply every filter. `bandsActive` is the band set the
// user clicked, OR the entry's locked band when Lock band
// is on. Mode lock compares the spot's inferred mode to
// the entry's current one.
const bandsActive = clusterLockBand
? new Set([band])
: clusterBands;
const search = clusterSearch.trim().toUpperCase();
let list = spots.filter((s) => {
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
if (search && !s.dx_call.includes(search)) return false;
if (clusterLockMode) {
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
// Treat empty inferred mode as wildcard so we don't
// hide perfectly good spots just because the comment
// was ambiguous.
if (spotMode && mode && spotMode !== mode) return false;
}
if (clusterStatusFilter.size > 0) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const st = spotStatus[k]?.status || '';
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
}
return true;
});
let rendered = list as (ClusterSpot & { repeats?: number })[];
if (clusterGroup) {
const seen = new Map<string, ClusterSpot & { repeats: number }>();
for (const s of list) {
const e = seen.get(s.dx_call);
if (e) { e.repeats++; }
else seen.set(s.dx_call, { ...s, repeats: 1 });
}
rendered = Array.from(seen.values());
{(() => {
// Apply every filter. `bandsActive` is the band set the
// user clicked, OR the entry's locked band when Lock band
// is on. Mode lock compares the spot's inferred mode to
// the entry's current one.
const bandsActive = clusterLockBand
? new Set([band])
: clusterBands;
const search = clusterSearch.trim().toUpperCase();
let list = spots.filter((s) => {
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
if (search && !s.dx_call.includes(search)) return false;
if (clusterLockMode) {
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
if (spotMode && mode && spotMode !== mode) return false;
}
// Apply sort. Time defaults to descending (newest first).
const dir = clusterSort.dir === 'asc' ? 1 : -1;
const cmp = (a: any, b: any) => (a < b ? -dir : a > b ? dir : 0);
rendered = [...rendered].sort((a, b) => {
switch (clusterSort.key) {
case 'time': return cmp(a.received_at, b.received_at);
case 'call': return cmp(a.dx_call, b.dx_call);
case 'freq': return cmp(a.freq_khz, b.freq_khz);
case 'band': return cmp(a.band ?? '', b.band ?? '');
case 'mode': return cmp(inferSpotMode(a.comment ?? '', a.freq_hz), inferSpotMode(b.comment ?? '', b.freq_hz));
case 'spotter': return cmp(cleanSpotter(a.spotter), cleanSpotter(b.spotter));
case 'source': return cmp(a.source_name, b.source_name);
}
});
if (rendered.length === 0) {
return (
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
<div className="text-sm font-semibold text-foreground/70">
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
</div>
<div className="text-xs">
{clusterServerStatuses.some((s) => s.state === 'connected')
? 'Spots will appear as the cluster sends them.'
: 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
</div>
</div>
);
}
const headers: { key: SortKey | null; label: string; align?: 'right' }[] = [
{ key: 'time', label: 'Time' },
{ key: 'call', label: 'Call' },
{ key: 'freq', label: 'Freq', align: 'right' },
{ key: 'band', label: 'Band' },
{ key: 'mode', label: 'Mode' },
{ key: null, label: 'Country' },
{ key: null, label: 'Cont' },
{ key: 'spotter', label: 'Spotter' },
{ key: 'source', label: 'Source' },
{ key: null, label: 'Loc' },
{ key: null, label: 'Comment' },
];
const toggleSort = (k: SortKey) => setClusterSort((s) =>
s.key === k
? { key: k, dir: s.dir === 'asc' ? 'desc' : 'asc' }
: { key: k, dir: k === 'time' || k === 'freq' ? 'desc' : 'asc' });
// Log4OM-style per-cell highlight: the badge that matches
// the "what's new" gets coloured instead of the whole row.
// CALL = new entity, BAND = new band for entity, MODE = new
// mode for that band (NEW SLOT — Log4OM doesn't show this
// but the user wants it).
const cellHL = (s: ClusterSpot) => {
if (clusterStatusFilter.size > 0) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
return spotStatus[k]?.status ?? '';
};
const st = spotStatus[k]?.status || '';
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
}
return true;
});
let rendered = list as (ClusterSpot & { repeats?: number })[];
if (clusterGroup) {
const seen = new Map<string, ClusterSpot & { repeats: number }>();
for (const s of list) {
const e = seen.get(s.dx_call);
if (e) { e.repeats++; }
else seen.set(s.dx_call, { ...s, repeats: 1 });
}
rendered = Array.from(seen.values());
}
if (rendered.length === 0) {
return (
<table className="w-full border-collapse text-[12.5px]">
<thead>
<tr>
{headers.map((h, i) => {
const sortable = h.key !== null;
const active = sortable && clusterSort.key === h.key;
return (
<th
key={i}
onClick={sortable ? () => toggleSort(h.key as SortKey) : undefined}
className={cn(
'px-2.5 py-1.5 font-semibold text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border sticky top-0',
h.align === 'right' ? 'text-right' : 'text-left',
sortable && 'cursor-pointer select-none hover:text-foreground',
active && 'text-primary',
)}
>
{h.label}
{active && (
<span className="ml-1 inline-block text-[9px]">
{clusterSort.dir === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{rendered.map((s, i) => {
const hl = cellHL(s);
const callCls = hl === 'new'
? 'bg-rose-100 text-rose-800 hover:bg-rose-200 border border-rose-300'
: 'text-primary';
const bandCls = hl === 'new-band'
? 'bg-amber-200 text-amber-900 border-amber-500 hover:bg-amber-200'
: '';
const modeMode = inferSpotMode(s.comment ?? '', s.freq_hz);
const modeCls = hl === 'new-slot'
? 'bg-yellow-200 text-yellow-900 border-yellow-500 hover:bg-yellow-200'
: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100';
return (
<tr
key={`${s.received_at}-${s.dx_call}-${i}`}
className="cursor-pointer hover:bg-accent/30"
onClick={() => {
// Mode comes from the spot itself (comment text
// first, band plan fallback). Sending it to CAT
// matters because skipping it leaves the rig
// on whatever it had — typically DIGU after a
// previous FT8 contact, which breaks a SSB click.
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
SetCATFrequency(s.freq_hz).catch(() => {});
if (m) SetCATMode(m).catch(() => {});
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
if (m) setMode(m);
}
onCallsignInput(s.dx_call);
}}
title={s.raw}
>
<td
className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40"
title={s.repeats && s.repeats > 1 ? `Seen ${s.repeats}× across active clusters` : undefined}
>{s.time_utc || ''}</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">
<span
className={cn('font-mono font-bold inline-block px-1 rounded', callCls)}
title={hl === 'new' ? `NEW DXCC: ${spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.country ?? ''}` : undefined}
>
{s.dx_call}
</span>
</td>
<td className="px-2.5 py-1.5 font-mono text-right whitespace-nowrap border-b border-border/40">{s.freq_khz.toFixed(1)}</td>
<td className="px-2.5 py-1.5 border-b border-border/40">
{bandCls
? <Badge className={cn('font-mono text-[10px] py-0', bandCls)} variant="outline" title="NEW BAND for this entity">{s.band || '—'}</Badge>
: <Badge variant="accent" className="font-mono text-[10px] py-0">{s.band || '—'}</Badge>}
</td>
<td className="px-2.5 py-1.5 border-b border-border/40">
{!modeMode
? <span className="text-muted-foreground text-[10px]"></span>
: <Badge className={cn('font-mono text-[10px] py-0', modeCls)} variant="outline" title={hl === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined}>{modeMode}</Badge>}
</td>
<td className="px-2.5 py-1.5 text-[12px] text-muted-foreground whitespace-nowrap border-b border-border/40">
{s.country ?? spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.country ?? ''}
</td>
<td className="px-2.5 py-1.5 font-mono text-muted-foreground text-[10px] whitespace-nowrap border-b border-border/40">
{s.continent ?? spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.continent ?? ''}
</td>
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{cleanSpotter(s.spotter)}</td>
<td className="px-2.5 py-1.5 font-mono text-muted-foreground/60 text-[10px] whitespace-nowrap border-b border-border/40">{s.source_name}</td>
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{s.locator || ''}</td>
<td className="px-2.5 py-1.5 text-muted-foreground border-b border-border/40">{s.comment}</td>
</tr>
);
})}
</tbody>
</table>
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
<div className="text-sm font-semibold text-foreground/70">
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
</div>
<div className="text-xs">
{clusterServerStatuses.some((s) => s.state === 'connected')
? 'Spots will appear as the cluster sends them.'
: 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
</div>
</div>
);
})()}
</div>
}
return (
<ClusterGrid
rows={rendered as any}
spotStatus={spotStatus}
onSpotClick={(s) => {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
SetCATFrequency(s.freq_hz).catch(() => {});
if (m) SetCATMode(m).catch(() => {});
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
if (m) setMode(m);
}
onCallsignInput(s.dx_call);
}}
/>
);
})()}
{/* Command input — sends to the master server. */}
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
@@ -1971,6 +1864,10 @@ export default function App() {
now in the topbar, visible on every tab. */}
</TabsContent>
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} />
</TabsContent>
{(['main','awards','propagation'] as const).map((t) => (
<TabsContent key={t} value={t} className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
@@ -1981,7 +1878,6 @@ export default function App() {
</Tabs>
</section>
<CallHistoryPanel wb={wb} busy={wbBusy} currentCall={callsign} />
{showBandMap && (
<div className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
<BandMap
+434
View File
@@ -0,0 +1,434 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Columns3 } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
ModuleRegistry.registerModules([AllCommunityModule]);
const hamlogTheme = themeQuartz.withParams({
fontFamily: 'inherit',
fontSize: 12.5,
backgroundColor: '#faf6ea',
foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9',
headerTextColor: '#5a4f3a',
headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0',
rowHoverColor: '#ecdcb4',
selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994',
rowBorder: { color: '#d8c9a8', width: 1 },
columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10,
rowHeight: 30,
headerHeight: 32,
spacing: 4,
accentColor: '#b8410c',
iconSize: 12,
});
export type ClusterSpot = {
source_id: number;
source_name: string;
spotter: string;
dx_call: string;
freq_khz: number;
freq_hz: number;
band?: string;
comment?: string;
locator?: string;
time_utc?: string;
country?: string;
continent?: string;
cqz?: number;
ituz?: number;
distance_km?: number;
sp_deg?: number;
lp_deg?: number;
received_at: string;
raw: string;
repeats?: number;
};
export type SpotStatusEntry = {
status?: string;
country?: string;
continent?: string;
worked_call?: boolean;
};
type Props = {
rows: ClusterSpot[];
spotStatus: Record<string, SpotStatusEntry>;
onSpotClick?: (s: ClusterSpot) => void;
};
const COL_STATE_KEY = 'hamlog.clusterColState.v1';
// Extracts the prefix from a callsign — drops portable suffixes (/P, /MM
// etc.), keeps a slashed prefix (HB0/DL2SBY → HB0), and trims the trailing
// digits after the last letter group (DL2SBY → DL2).
function fmtPfx(call: string): string {
if (!call) return '';
const c = call.trim().toUpperCase();
const base = c.includes('/') ? c.split('/')[0] : c;
// If "base" is a callsign rather than a bare prefix (like DL2SBY), cut
// at the last digit to get DL2.
let lastDigit = -1;
for (let i = 0; i < base.length; i++) {
if (base[i] >= '0' && base[i] <= '9') lastDigit = i;
}
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
}
// Renders an ISO timestamp (RFC3339 with nanoseconds) as a compact UTC
// "YYYY-MM-DD HH:MM:SS" string — matches the rest of the app's date style.
function fmtDateTimeUTC(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return String(s);
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())}:${p(d.getUTCSeconds())}`;
}
type ColEntry = ColDef<ClusterSpot> & { group: string; label: string; defaultVisible?: boolean };
const COL_CATALOG: ColEntry[] = [
{
group: 'Spot', label: 'Time', colId: 'time',
headerName: 'Time', field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
defaultVisible: true,
sort: 'desc',
cellStyle: { color: '#7a6b50' },
},
{
group: 'Spot', label: 'Call', colId: 'call',
headerName: 'Call', field: 'dx_call' as any, width: 120,
defaultVisible: true,
cellRenderer: (p: any) => {
if (!p.value) return '';
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
];
const isNew = status?.status === 'new';
const workedCall = !!status?.worked_call;
const style: any = {
display: 'inline-block', padding: '1px 6px', borderRadius: 4,
fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12,
};
if (isNew) {
style.backgroundColor = '#ffe4e6';
style.color = '#9f1239';
style.border = '1px solid #fda4af';
} else if (workedCall) {
style.color = '#0369a1';
} else {
style.color = '#b8410c';
}
return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>;
},
},
{
group: 'Spot', label: 'Freq', colId: 'freq',
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
defaultVisible: true,
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
group: 'Spot', label: 'Band', colId: 'band',
headerName: 'Band', field: 'band' as any, width: 75,
defaultVisible: true,
cellClass: 'flex items-center',
cellRenderer: (p: any) => {
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
];
const newBand = status?.status === 'new-band';
const bg = newBand ? '#fde68a' : '#f0d9a8';
const fg = newBand ? '#92400e' : '#7a4a14';
return p.value
? <span
style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: bg, color: fg, lineHeight: '16px',
border: newBand ? '1px solid #f59e0b' : undefined,
}}
title={newBand ? 'NEW BAND for this entity' : undefined}
>{p.value}</span>
: '';
},
},
{
group: 'Spot', label: 'Mode', colId: 'mode',
headerName: 'Mode', colSpan: undefined, width: 80,
defaultVisible: true,
cellClass: 'flex items-center',
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
cellRenderer: (p: any) => {
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
];
const newSlot = status?.status === 'new-slot';
const bg = newSlot ? '#fef08a' : '#d1fae5';
const fg = newSlot ? '#854d0e' : '#047857';
return p.value
? <span
style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: bg, color: fg, lineHeight: '16px',
border: newSlot ? '1px solid #eab308' : undefined,
}}
title={newSlot ? 'NEW SLOT (mode not yet worked on this band)' : undefined}
>{p.value}</span>
: <span style={{ color: '#a8a29e', fontSize: 10 }}></span>;
},
},
{
group: 'Spot', label: 'Pfx', colId: 'pfx',
headerName: 'Pfx', width: 60, cellClass: 'font-mono',
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
cellStyle: { color: '#7a6b50' },
},
{
group: 'Geo', label: 'CQ Zone', colId: 'cqz',
headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(p.value) : '',
},
{
group: 'Geo', label: 'ITU Zone', colId: 'ituz',
headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(p.value) : '',
},
{
group: 'Geo', label: 'Distance (km)', colId: 'distance_km',
headerName: 'Dist km', field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(p.value) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
group: 'Geo', label: 'Short path (°)', colId: 'sp_deg',
headerName: 'SP°', field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
group: 'Geo', label: 'Long path (°)', colId: 'lp_deg',
headerName: 'LP°', field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
group: 'Spot', label: 'Country', colId: 'country',
headerName: 'Country', width: 140,
defaultVisible: true,
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
]?.country ?? '',
cellStyle: { color: '#7a6b50' },
},
{
group: 'Spot', label: 'Continent', colId: 'continent',
headerName: 'Cont', width: 60, cellClass: 'font-mono',
defaultVisible: true,
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
]?.continent ?? '',
cellStyle: { color: '#7a6b50', fontSize: 10 },
},
{
group: 'Spot', label: 'Spotter', colId: 'spotter',
headerName: 'Spotter', field: 'spotter' as any, width: 100, cellClass: 'font-mono',
defaultVisible: true,
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
cellStyle: { color: '#7a6b50' },
},
{
group: 'Spot', label: 'Source', colId: 'source',
headerName: 'Source', field: 'source_name' as any, width: 100,
defaultVisible: true,
cellStyle: { color: '#9a8870', fontSize: 10 },
},
{
group: 'Spot', label: 'Locator', colId: 'locator',
headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono',
cellStyle: { color: '#7a6b50' },
},
{
group: 'Spot', label: 'Comment', colId: 'comment',
headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160,
defaultVisible: true,
cellStyle: { color: '#7a6b50' },
},
{
group: 'Spot', label: 'Received at', colId: 'received_at',
headerName: 'Received UTC', field: 'received_at' as any, width: 160, cellClass: 'font-mono',
valueFormatter: (p) => fmtDateTimeUTC(p.value),
},
{
group: 'Spot', label: 'Raw', colId: 'raw',
headerName: 'Raw', field: 'raw' as any, width: 300, cellClass: 'font-mono',
},
];
const GROUP_ORDER = ['Spot', 'Geo'];
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), []);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false,
}), []);
// Pass spotStatus through AG Grid's context so cell renderers can look up
// per-cell highlight without re-rendering the whole grid when the map
// updates. We refresh cells whose values depend on it after each prop
// change below.
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
function onGridReady(e: GridReadyEvent) {
try {
const raw = localStorage.getItem(COL_STATE_KEY);
if (raw) {
const state = JSON.parse(raw) as ColumnState[];
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
}
} catch {}
}
const saveColumnState = useCallback(() => {
try {
const state = gridRef.current?.api?.getColumnState();
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
} catch {}
}, []);
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
if (e.data && onSpotClick) onSpotClick(e.data);
}
function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId);
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
}
function setColVisible(colId: string, visible: boolean) {
const api = gridRef.current?.api;
if (!api) return;
api.setColumnsVisible([colId], visible);
saveColumnState();
}
function showAll(group?: string) {
const api = gridRef.current?.api;
if (!api) return;
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
api.setColumnsVisible(ids, true);
saveColumnState();
}
function hideAll(group?: string) {
const api = gridRef.current?.api;
if (!api) return;
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
api.setColumnsVisible(ids, false);
saveColumnState();
}
function resetDefaults() {
const api = gridRef.current?.api;
if (!api) return;
const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!);
const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!);
api.setColumnsVisible(visible, true);
api.setColumnsVisible(hidden, false);
saveColumnState();
}
return (
<>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> Columns
</Button>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<ClusterSpot>
ref={gridRef}
theme={hamlogTheme}
rowData={rows}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
context={context}
onGridReady={onGridReady}
onColumnResized={saveColumnState}
onColumnMoved={saveColumnState}
onColumnPinned={saveColumnState}
onColumnVisible={saveColumnState}
onSortChanged={saveColumnState}
onRowClicked={handleRowClicked}
animateRows={false}
suppressCellFocus
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
/>
</div>
</div>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Cluster columns</DialogTitle>
<DialogDescription>
Pick the columns you want visible in the Cluster table.
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto py-2">
{GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group);
if (cols.length === 0) return null;
return (
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
</div>
</div>
<div className="grid grid-cols-2 gap-1">
{cols.map((c) => (
<label key={c.colId} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
<Checkbox
checked={isColVisible(c.colId!)}
onCheckedChange={(v) => setColVisible(c.colId!, !!v)}
/>
{c.label}
</label>
))}
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+26 -1
View File
@@ -31,12 +31,27 @@ export function Menubar({ menus, onAction }: Props) {
key={menu.name}
open={openMenu === menu.name}
onOpenChange={(o) => setOpenMenu(o ? menu.name : null)}
modal={false}
>
<DropdownMenuTrigger
onMouseEnter={() => {
// Only switch on hover if a menu is already open.
if (openMenu !== null && openMenu !== menu.name) setOpenMenu(menu.name);
}}
onPointerDown={(e) => {
// Desktop-menubar behaviour: when another menu is already
// open, a click on a different trigger should switch to it
// in one click. Without this Radix consumes the click to
// close the current menu first, requiring a second click
// to open the new one. We pre-empt by setting open state
// synchronously and stopping the event from reaching the
// default Radix toggle.
if (openMenu !== null && openMenu !== menu.name) {
e.preventDefault();
e.stopPropagation();
setOpenMenu(menu.name);
}
}}
className={cn(
'px-3 text-sm rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring',
openMenu === menu.name && 'bg-muted text-primary',
@@ -44,7 +59,17 @@ export function Menubar({ menus, onAction }: Props) {
>
{menu.label}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={4} className="min-w-[240px]">
<DropdownMenuContent
align="start" sideOffset={4} className="min-w-[240px]"
onCloseAutoFocus={(e) => {
// Radix re-focuses the trigger after close. Combined with our
// focus-visible:ring style this leaves an orange outline around
// the previously-clicked menu — looks like a stuck "selected"
// state. We swallow the auto-focus and let the next interaction
// decide where focus belongs.
e.preventDefault();
}}
>
{menu.items.map((item, i) =>
item.type === 'separator' ? (
<DropdownMenuSeparator key={i} />
+451
View File
@@ -0,0 +1,451 @@
import { useCallback, useEffect, useState } from 'react';
import {
Antenna as AntennaIcon, Radio, Plus, Trash2, Star,
ChevronRight, ChevronDown, Edit2,
} from 'lucide-react';
import {
ListOperatingTree, SaveOperatingStation, DeleteOperatingStation,
SaveOperatingAntenna, DeleteOperatingAntenna,
} from '../../wailsjs/go/main/App';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
type Band = { band: string; is_default: boolean };
type Antenna = {
id: number;
station_id: number;
name: string;
sort_order: number;
bands: Band[];
};
type Station = {
id: number;
profile_id: number;
name: string;
tx_pwr?: number;
sort_order: number;
antennas?: Antenna[];
};
type Props = {
/** ADIF bands available to toggle, in display order (from ListsSettings). */
bands: string[];
/** External error sink — parent shows it next to the Save button. */
onError: (msg: string) => void;
};
export function OperatingPanel({ bands, onError }: Props) {
const [tree, setTree] = useState<Station[]>([]);
const [loading, setLoading] = useState(true);
// expanded keeps which stations show their antennas; everything open by
// default so the user sees the full setup at a glance.
const [expanded, setExpanded] = useState<Set<number>>(new Set());
// editingId tracks the row currently in edit mode. Use a string namespace
// to keep station ids and antenna ids in the same Set.
const [editing, setEditing] = useState<string | null>(null);
const reload = useCallback(async () => {
try {
const t = await ListOperatingTree();
const list = (t ?? []) as Station[];
setTree(list);
setExpanded((prev) => {
if (prev.size > 0) return prev;
return new Set(list.map((s) => s.id));
});
} catch (e: any) {
onError(String(e?.message ?? e));
} finally {
setLoading(false);
}
}, [onError]);
useEffect(() => { void reload(); }, [reload]);
function toggleExpanded(id: number) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
async function addStation() {
try {
const created = await SaveOperatingStation({
id: 0, profile_id: 0, name: 'New rig', sort_order: tree.length,
} as any);
const c = created as Station;
setTree((prev) => [...prev, { ...c, antennas: [] }]);
setExpanded((prev) => new Set(prev).add(c.id));
setEditing(`station:${c.id}`);
} catch (e: any) { onError(String(e?.message ?? e)); }
}
async function updateStation(s: Station) {
try {
const saved = await SaveOperatingStation(s as any) as Station;
setTree((prev) => prev.map((x) => x.id === s.id ? { ...x, ...saved } : x));
} catch (e: any) { onError(String(e?.message ?? e)); }
}
async function removeStation(id: number) {
if (!confirm('Delete this rig and all its antennas?')) return;
try {
await DeleteOperatingStation(id);
setTree((prev) => prev.filter((s) => s.id !== id));
} catch (e: any) { onError(String(e?.message ?? e)); }
}
async function addAntenna(stationId: number) {
try {
const created = await SaveOperatingAntenna({
id: 0, station_id: stationId, name: 'New antenna', sort_order: 0, bands: [],
} as any) as Antenna;
setTree((prev) => prev.map((s) =>
s.id === stationId
? { ...s, antennas: [...(s.antennas ?? []), created] }
: s
));
setEditing(`antenna:${created.id}`);
} catch (e: any) { onError(String(e?.message ?? e)); }
}
async function updateAntenna(a: Antenna) {
try {
const saved = await SaveOperatingAntenna(a as any) as Antenna;
setTree((prev) => prev.map((s) => s.id === a.station_id
? {
...s,
antennas: (s.antennas ?? []).map((x) => x.id === a.id ? saved : x),
}
: {
// The save may have cleared is_default on antennas of OTHER
// stations (one default per band per profile). Refresh those
// by reloading the tree wholesale.
...s,
}
));
// Reload to pick up cross-station default flips.
void reload();
} catch (e: any) { onError(String(e?.message ?? e)); }
}
async function removeAntenna(stationId: number, antId: number) {
if (!confirm('Delete this antenna?')) return;
try {
await DeleteOperatingAntenna(antId);
setTree((prev) => prev.map((s) =>
s.id === stationId
? { ...s, antennas: (s.antennas ?? []).filter((a) => a.id !== antId) }
: s
));
} catch (e: any) { onError(String(e?.message ?? e)); }
}
if (loading) {
return <div className="text-xs text-muted-foreground italic">Loading</div>;
}
return (
<div className="space-y-3">
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
Define your rigs (stations) and the antennas connected to each one.
For every antenna, tick the bands it covers. <Star className="inline size-3 text-amber-500 fill-current align-text-bottom" /> marks
the default antenna for that band when you change the band in the
entry strip, the matching rig + antenna auto-fill the MY_RIG and
MY_ANTENNA ADIF fields. Only one antenna can be the default per
band; setting one clears the previous default.
</div>
<div className="flex gap-2">
<Button size="sm" onClick={addStation}>
<Plus className="size-3.5" /> Add rig
</Button>
</div>
{tree.length === 0 ? (
<div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground italic">
No rig configured yet. Click "Add rig" to get started.
</div>
) : (
<div className="space-y-2">
{tree.map((station) => (
<StationRow
key={station.id}
station={station}
bands={bands}
expanded={expanded.has(station.id)}
editing={editing}
setEditing={setEditing}
onToggleExpanded={() => toggleExpanded(station.id)}
onUpdate={updateStation}
onDelete={() => removeStation(station.id)}
onAddAntenna={() => addAntenna(station.id)}
onUpdateAntenna={updateAntenna}
onDeleteAntenna={(antId) => removeAntenna(station.id, antId)}
/>
))}
</div>
)}
</div>
);
}
// ── Station row ────────────────────────────────────────────────────────
type StationRowProps = {
station: Station;
bands: string[];
expanded: boolean;
editing: string | null;
setEditing: (id: string | null) => void;
onToggleExpanded: () => void;
onUpdate: (s: Station) => void;
onDelete: () => void;
onAddAntenna: () => void;
onUpdateAntenna: (a: Antenna) => void;
onDeleteAntenna: (antId: number) => void;
};
function StationRow({
station, bands, expanded, editing, setEditing,
onToggleExpanded, onUpdate, onDelete, onAddAntenna,
onUpdateAntenna, onDeleteAntenna,
}: StationRowProps) {
const editKey = `station:${station.id}`;
const isEditing = editing === editKey;
const [draft, setDraft] = useState({
name: station.name,
tx_pwr: station.tx_pwr != null ? String(station.tx_pwr) : '',
});
useEffect(() => {
if (!isEditing) setDraft({
name: station.name,
tx_pwr: station.tx_pwr != null ? String(station.tx_pwr) : '',
});
}, [isEditing, station.name, station.tx_pwr]);
function commit() {
const pwrNum = draft.tx_pwr.trim() === '' ? undefined : parseFloat(draft.tx_pwr);
onUpdate({
...station,
name: draft.name.trim() || station.name,
tx_pwr: Number.isFinite(pwrNum as number) ? (pwrNum as number) : undefined,
});
setEditing(null);
}
return (
<div className="rounded-md border border-border bg-card">
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border/60 bg-muted/30">
<button
type="button"
onClick={onToggleExpanded}
className="p-0.5 hover:bg-accent/40 rounded"
title={expanded ? 'Collapse' : 'Expand'}
>
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
</button>
<Radio className="size-4 text-muted-foreground" />
{isEditing ? (
<div className="flex items-center gap-2 flex-1">
<Input
autoFocus
className="h-7 text-sm flex-1"
placeholder="Rig name (also stamped as MY_RIG)"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
/>
<Label className="text-[11px] text-muted-foreground">Power (W)</Label>
<Input
type="number"
min={0}
step={1}
className="h-7 text-sm w-20 font-mono"
placeholder="100"
value={draft.tx_pwr}
onChange={(e) => setDraft((d) => ({ ...d, tx_pwr: e.target.value }))}
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
/>
<Button size="sm" onClick={commit}>Save</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
</div>
) : (
<>
<span className="font-semibold text-sm">{station.name}</span>
{station.tx_pwr != null && (
<span className="text-[11px] text-muted-foreground font-mono">{station.tx_pwr} W</span>
)}
<div className="flex-1" />
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title="Edit">
<Edit2 className="size-3" />
</Button>
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={onAddAntenna}>
<Plus className="size-3" /> Antenna
</Button>
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title="Delete rig">
<Trash2 className="size-3" />
</Button>
</>
)}
</div>
{expanded && (
<div className="p-2 space-y-2">
{(station.antennas ?? []).length === 0 ? (
<div className="text-[11px] text-muted-foreground italic pl-6">
No antenna yet click "Antenna" above to add one.
</div>
) : (
(station.antennas ?? []).map((a) => (
<AntennaRow
key={a.id}
antenna={a}
bands={bands}
editing={editing}
setEditing={setEditing}
onUpdate={onUpdateAntenna}
onDelete={() => onDeleteAntenna(a.id)}
/>
))
)}
</div>
)}
</div>
);
}
// ── Antenna row ────────────────────────────────────────────────────────
type AntennaRowProps = {
antenna: Antenna;
bands: string[];
editing: string | null;
setEditing: (id: string | null) => void;
onUpdate: (a: Antenna) => void;
onDelete: () => void;
};
function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) {
const editKey = `antenna:${antenna.id}`;
const isEditing = editing === editKey;
const [draft, setDraft] = useState({ name: antenna.name });
useEffect(() => {
if (!isEditing) setDraft({ name: antenna.name });
}, [isEditing, antenna.name]);
const enabledBands = new Map<string, Band>(
(antenna.bands ?? []).map((b) => [b.band, b])
);
function commitNames() {
onUpdate({
...antenna,
name: draft.name.trim() || antenna.name,
bands: antenna.bands ?? [],
});
setEditing(null);
}
function toggleBand(band: string, on: boolean) {
let next = [...(antenna.bands ?? [])];
if (on) {
if (!next.find((b) => b.band === band)) {
next.push({ band, is_default: false });
}
} else {
next = next.filter((b) => b.band !== band);
}
onUpdate({ ...antenna, bands: next });
}
function setDefault(band: string, isDefault: boolean) {
const next = (antenna.bands ?? []).map((b) =>
b.band === band ? { ...b, is_default: isDefault } : b
);
onUpdate({ ...antenna, bands: next });
}
return (
<div className="rounded border border-border/70 bg-muted/10">
<div className="flex items-center gap-2 px-2 py-1.5">
<AntennaIcon className="size-3.5 text-muted-foreground ml-3" />
{isEditing ? (
<div className="flex items-center gap-2 flex-1">
<Input
autoFocus
className="h-7 text-sm flex-1"
placeholder="Antenna name (also stamped as MY_ANTENNA)"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }}
/>
<Button size="sm" onClick={commitNames}>Save</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
</div>
) : (
<>
<span className="text-sm font-medium">{antenna.name}</span>
<div className="flex-1" />
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)}>
<Edit2 className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete}>
<Trash2 className="size-3" />
</Button>
</>
)}
</div>
<div className="px-3 pb-2 pl-8 flex flex-wrap gap-1.5">
{bands.length === 0 ? (
<span className="text-[10px] text-muted-foreground italic">No band configured in Settings Bands.</span>
) : bands.map((band) => {
const entry = enabledBands.get(band);
const enabled = !!entry;
const isDefault = !!entry?.is_default;
return (
<div
key={band}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
isDefault
? 'border-amber-400 bg-amber-50 shadow-sm'
: enabled
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-muted/30 text-muted-foreground'
)}
>
<Checkbox
checked={enabled}
onCheckedChange={(c) => toggleBand(band, !!c)}
className="size-3"
/>
<span className={isDefault ? 'font-semibold' : undefined}>{band}</span>
{enabled && (
<button
type="button"
onClick={() => setDefault(band, !isDefault)}
className={cn(
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
isDefault
? 'bg-amber-400 text-white'
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-amber-500 hover:text-amber-700',
)}
title={isDefault ? 'Default antenna for this band — click to unset' : 'Click to make this antenna the default for this band'}
>
<Star className={cn('size-3', isDefault && 'fill-current')} />
<span className="text-[9px] font-bold uppercase tracking-wider">
{isDefault ? 'Default' : 'Set'}
</span>
</button>
)}
</div>
);
})}
</div>
</div>
);
}
+373
View File
@@ -0,0 +1,373 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Columns3 } from 'lucide-react';
import type { QSOForm } from '@/types';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
// Register every Community feature once. v32+ requires explicit registration;
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
// virtual-scroll — everything we want out of the box for a logbook table.
ModuleRegistry.registerModules([AllCommunityModule]);
// Custom Quartz theme tuned to match HamLog's warm palette.
const hamlogTheme = themeQuartz.withParams({
fontFamily: 'inherit',
fontSize: 12.5,
backgroundColor: '#faf6ea',
foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9',
headerTextColor: '#5a4f3a',
headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0',
rowHoverColor: '#ecdcb4',
selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994',
rowBorder: { color: '#d8c9a8', width: 1 },
columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10,
rowHeight: 32,
headerHeight: 34,
spacing: 4,
accentColor: '#b8410c',
iconSize: 12,
});
const badgeCellClass = 'flex items-center';
type Props = {
rows: QSOForm[];
total: number;
onRowDoubleClicked?: (q: QSOForm) => void;
onRowSelected?: (id: number | null) => void;
};
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
function fmtMhzDots(hz?: number): string {
if (!hz) return '';
const mhz = (hz / 1_000_000).toFixed(6);
const [i, f] = mhz.split('.');
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
}
function fmtDateUTC(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return s;
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())}`;
}
function fmtDateOnly(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return s;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
const bandPill = (p: any) => p.value
? <span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
}}>{p.value}</span>
: '';
const modePill = (p: any) => p.value
? <span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
}}>{p.value}</span>
: '';
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
// = shown out of the box; anything else stays hidden until the user toggles
// it in the Columns dialog.
type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
const COL_CATALOG: ColEntry[] = [
// ── QSO basics ──
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
{ group: 'QSO', label: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: badgeCellClass, cellRenderer: bandPill, defaultVisible: true },
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: badgeCellClass, cellRenderer: bandPill },
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: badgeCellClass, cellRenderer: modePill, defaultVisible: true },
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
// ── Contacted station ──
{ group: 'Contacted', label: 'Name', colId: 'name', headerName: 'Name', field: 'name' as any, width: 170, defaultVisible: true },
{ group: 'Contacted', label: 'QTH', colId: 'qth', headerName: 'QTH', field: 'qth' as any, width: 200, defaultVisible: true },
{ group: 'Contacted', label: 'Address', colId: 'address', headerName: 'Address', field: 'address' as any, width: 200 },
{ group: 'Contacted', label: 'Country', colId: 'country', headerName: 'Country', field: 'country' as any, width: 150, defaultVisible: true },
{ group: 'Contacted', label: 'State', colId: 'state', headerName: 'State', field: 'state' as any, width: 80 },
{ group: 'Contacted', label: 'County', colId: 'cnty', headerName: 'County', field: 'cnty' as any, width: 130 },
{ group: 'Contacted', label: 'Continent',colId: 'cont', headerName: 'Cont', field: 'cont' as any, width: 60 },
{ group: 'Contacted', label: 'Grid', colId: 'grid', headerName: 'Grid', field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'Contacted', label: 'Grid Ext', colId: 'gridsquare_ext', headerName: 'GridExt', field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
{ group: 'Contacted', label: 'VUCC grids',colId: 'vucc_grids', headerName: 'VUCC', field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
{ group: 'Contacted', label: 'DXCC #', colId: 'dxcc', headerName: 'DXCC #', field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: 'CQZ', colId: 'cqz', headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: 'ITU', colId: 'ituz', headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: 'IOTA', colId: 'iota', headerName: 'IOTA', field: 'iota' as any, width: 80, cellClass: 'font-mono' },
{ group: 'Contacted', label: 'SOTA ref', colId: 'sota_ref', headerName: 'SOTA', field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'Contacted', label: 'POTA ref', colId: 'pota_ref', headerName: 'POTA', field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'Contacted', label: 'Age', colId: 'age', headerName: 'Age', field: 'age' as any, width: 60, type: 'rightAligned' },
{ group: 'Contacted', label: 'Lat', colId: 'lat', headerName: 'Lat', field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: 'Lon', colId: 'lon', headerName: 'Lon', field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: 'Email', colId: 'email', headerName: 'Email', field: 'email' as any, width: 180 },
{ group: 'Contacted', label: 'Web', colId: 'web', headerName: 'Web', field: 'web' as any, width: 180 },
// ── QSL ──
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL sent', field: 'qsl_sent' as any, width: 80 },
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL rcvd', field: 'qsl_rcvd' as any, width: 80 },
{ group: 'QSL', label: 'QSL sent date',colId: 'qsl_sent_date', headerName: 'QSL S date', field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: 'QSL rcvd date',colId: 'qsl_rcvd_date', headerName: 'QSL R date', field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: 'QSL via', colId: 'qsl_via', headerName: 'QSL via', field: 'qsl_via' as any, width: 130 },
{ group: 'QSL', label: 'QSL msg', colId: 'qsl_msg', headerName: 'QSL msg', field: 'qsl_msg' as any, width: 200 },
{ group: 'QSL', label: 'QSL msg rcvd', colId: 'qslmsg_rcvd', headerName: 'QSL msg rcvd', field: 'qslmsg_rcvd' as any, width: 200 },
// ── LoTW ──
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW sent', field: 'lotw_sent' as any, width: 80 },
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW rcvd', field: 'lotw_rcvd' as any, width: 80 },
{ group: 'LoTW', label: 'LoTW sent date', colId: 'lotw_sent_date', headerName: 'LoTW S date', field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'LoTW', label: 'LoTW rcvd date', colId: 'lotw_rcvd_date', headerName: 'LoTW R date', field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// ── eQSL ──
{ group: 'eQSL', label: 'eQSL sent', colId: 'eqsl_sent', headerName: 'eQSL sent', field: 'eqsl_sent' as any, width: 80 },
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// ── Uploads ──
{ group: 'Uploads', label: 'ClubLog upload date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog date', field: 'clublog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: 'ClubLog upload status', colId: 'clublog_qso_upload_status', headerName: 'ClubLog status', field: 'clublog_qso_upload_status' as any, width: 110 },
{ group: 'Uploads', label: 'HRDLog upload date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog date', field: 'hrdlog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 },
// ── Contest ──
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
{ group: 'Contest', label: 'SRX', colId: 'srx', headerName: 'SRX', field: 'srx' as any, width: 60, type: 'rightAligned' },
{ group: 'Contest', label: 'STX', colId: 'stx', headerName: 'STX', field: 'stx' as any, width: 60, type: 'rightAligned' },
{ group: 'Contest', label: 'SRX string', colId: 'srx_string', headerName: 'SRX str', field: 'srx_string' as any, width: 100 },
{ group: 'Contest', label: 'STX string', colId: 'stx_string', headerName: 'STX str', field: 'stx_string' as any, width: 100 },
{ group: 'Contest', label: 'Check', colId: 'check', headerName: 'Check', field: 'check' as any, width: 70 },
{ group: 'Contest', label: 'Precedence', colId: 'precedence', headerName: 'Precedence', field: 'precedence' as any, width: 90 },
{ group: 'Contest', label: 'ARRL section',colId: 'arrl_sect', headerName: 'ARRL sect', field: 'arrl_sect' as any, width: 90 },
// ── Propagation / antenna ──
{ group: 'Propagation', label: 'Prop mode', colId: 'prop_mode', headerName: 'Prop', field: 'prop_mode' as any, width: 80 },
{ group: 'Propagation', label: 'Sat name', colId: 'sat_name', headerName: 'Sat', field: 'sat_name' as any, width: 110 },
{ group: 'Propagation', label: 'Sat mode', colId: 'sat_mode', headerName: 'Sat mode', field: 'sat_mode' as any, width: 80 },
{ group: 'Propagation', label: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' },
{ group: 'Propagation', label: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' as any, width: 70, type: 'rightAligned' },
{ group: 'Propagation', label: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 },
{ group: 'Propagation', label: 'Rig', colId: 'rig', headerName: 'Rig', field: 'rig' as any, width: 120 },
{ group: 'Propagation', label: 'Antenna', colId: 'ant', headerName: 'Antenna', field: 'ant' as any, width: 140 },
// ── My station (operator side) ──
{ group: 'My station', label: 'Station call', colId: 'station_callsign', headerName: 'Station', field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
{ group: 'My station', label: 'Operator', colId: 'operator', headerName: 'Operator',field: 'operator' as any, width: 100, cellClass: 'font-mono' },
{ group: 'My station', label: 'My grid', colId: 'my_grid', headerName: 'My grid', field: 'my_grid' as any, width: 85, cellClass: 'font-mono' },
{ group: 'My station', label: 'My country', colId: 'my_country', headerName: 'My ctry', field: 'my_country' as any, width: 130 },
{ group: 'My station', label: 'My state', colId: 'my_state', headerName: 'My state',field: 'my_state' as any, width: 80 },
{ group: 'My station', label: 'My county', colId: 'my_cnty', headerName: 'My cnty', field: 'my_cnty' as any, width: 110 },
{ group: 'My station', label: 'My IOTA', colId: 'my_iota', headerName: 'My IOTA', field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
{ group: 'My station', label: 'My SOTA', colId: 'my_sota_ref', headerName: 'My SOTA', field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'My station', label: 'My POTA', colId: 'my_pota_ref', headerName: 'My POTA', field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'My station', label: 'My DXCC', colId: 'my_dxcc', headerName: 'My DXCC#',field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
{ group: 'My station', label: 'My CQ zone', colId: 'my_cq_zone', headerName: 'My CQZ', field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' },
{ group: 'My station', label: 'My ITU zone', colId: 'my_itu_zone', headerName: 'My ITU', field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' },
{ group: 'My station', label: 'My lat', colId: 'my_lat', headerName: 'My lat', field: 'my_lat' as any, width: 90, type: 'rightAligned' },
{ group: 'My station', label: 'My lon', colId: 'my_lon', headerName: 'My lon', field: 'my_lon' as any, width: 90, type: 'rightAligned' },
{ group: 'My station', label: 'My street', colId: 'my_street', headerName: 'Street', field: 'my_street' as any, width: 160 },
{ group: 'My station', label: 'My city', colId: 'my_city', headerName: 'City', field: 'my_city' as any, width: 130 },
{ group: 'My station', label: 'My ZIP', colId: 'my_postal_code', headerName: 'ZIP', field: 'my_postal_code' as any, width: 80 },
{ group: 'My station', label: 'My rig', colId: 'my_rig', headerName: 'My rig', field: 'my_rig' as any, width: 130 },
{ group: 'My station', label: 'My antenna', colId: 'my_antenna', headerName: 'My ant', field: 'my_antenna' as any, width: 130 },
// ── Misc ──
{ group: 'Misc', label: 'Comment', colId: 'comment', headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
{ group: 'Misc', label: 'Notes', colId: 'notes', headerName: 'Notes', field: 'notes' as any, width: 240 },
{ group: 'Misc', label: 'Created', colId: 'created_at', headerName: 'Created at', field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
{ group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
];
const GROUP_ORDER = [
'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads',
'Contest', 'Propagation', 'My station', 'Misc',
];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
// Compute initial column defs: all columns defined, but those not marked
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
// overrides this so a previously toggled column wins.
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), []);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true,
resizable: true,
filter: true,
suppressMovable: false,
}), []);
function onGridReady(e: GridReadyEvent) {
try {
const raw = localStorage.getItem(COL_STATE_KEY);
if (raw) {
const state = JSON.parse(raw) as ColumnState[];
if (Array.isArray(state)) {
e.api.applyColumnState({ state, applyOrder: true });
}
}
} catch {}
}
const saveColumnState = useCallback(() => {
try {
const state = gridRef.current?.api?.getColumnState();
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
} catch {}
}, []);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
}
function onSelectionChanged() {
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
}
// ── Column picker (visibility) ──
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
// state for "which columns are visible" — AG Grid's column state is the
// source of truth, and saveColumnState persists it.
function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId);
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
}
function setColVisible(colId: string, visible: boolean) {
const api = gridRef.current?.api;
if (!api) return;
api.setColumnsVisible([colId], visible);
saveColumnState();
}
function showAll(group?: string) {
const api = gridRef.current?.api;
if (!api) return;
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
api.setColumnsVisible(ids, true);
saveColumnState();
}
function hideAll(group?: string) {
const api = gridRef.current?.api;
if (!api) return;
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
api.setColumnsVisible(ids, false);
saveColumnState();
}
function resetDefaults() {
const api = gridRef.current?.api;
if (!api) return;
const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!);
const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!);
api.setColumnsVisible(visible, true);
api.setColumnsVisible(hidden, false);
saveColumnState();
}
return (
<>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> Columns
</Button>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<QSOForm>
ref={gridRef}
theme={hamlogTheme}
rowData={rows}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
onGridReady={onGridReady}
onColumnResized={saveColumnState}
onColumnMoved={saveColumnState}
onColumnPinned={saveColumnState}
onColumnVisible={saveColumnState}
onSortChanged={saveColumnState}
onRowDoubleClicked={handleRowDoubleClicked}
onSelectionChanged={onSelectionChanged}
animateRows={false}
suppressCellFocus
getRowId={(p) => String((p.data as any).id)}
/>
</div>
</div>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Columns</DialogTitle>
<DialogDescription>
Pick the columns you want visible in the Recent QSOs table.
Your selection is saved.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto py-2">
{GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group);
if (cols.length === 0) return null;
return (
<div key={group} className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
</div>
</div>
<div className="flex flex-col gap-1">
{cols.map((c) => (
<label key={c.colId} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
<Checkbox
checked={isColVisible(c.colId!)}
onCheckedChange={(v) => setColVisible(c.colId!, !!v)}
/>
{c.label}
</label>
))}
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+452 -67
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import {
ArrowDown, ArrowUp, Copy, Plus, Star, StarOff, Trash2,
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction,
@@ -15,11 +15,12 @@ import {
GetClusterAutoConnect, SetClusterAutoConnect,
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models';
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
@@ -33,6 +34,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { OperatingPanel } from '@/components/OperatingPanel';
type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm;
@@ -44,6 +46,55 @@ type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
type Profile = Omit<profileModels.Profile, 'convertValues'>;
// Catalog of all standard ADIF bands, in natural frequency order. The user
// picks a subset on the right; everything else in the UI (entry strip,
// band-slot grid, band-map switcher) iterates that subset.
const BAND_CATALOG = [
'2190m','630m','560m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m',
'8m','6m','5m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm',
'6mm','4mm','2.5mm','2mm','1mm',
];
// Catalog of common ADIF modes with sensible RST defaults. When the user
// picks one on the right, the RSTs are pre-filled but stay editable.
const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [
{ name: 'SSB', sent: '59', rcvd: '59' },
{ name: 'CW', sent: '599', rcvd: '599' },
{ name: 'AM', sent: '59', rcvd: '59' },
{ name: 'FM', sent: '59', rcvd: '59' },
{ name: 'DIGITALVOICE', sent: '59', rcvd: '59' },
{ name: 'FT8', sent: '-10', rcvd: '-10' },
{ name: 'FT4', sent: '-10', rcvd: '-10' },
{ name: 'JS8', sent: '-10', rcvd: '-10' },
{ name: 'MSK144', sent: '+00', rcvd: '+00' },
{ name: 'JT65', sent: '-15', rcvd: '-15' },
{ name: 'JT9', sent: '-15', rcvd: '-15' },
{ name: 'Q65', sent: '-15', rcvd: '-15' },
{ name: 'FST4', sent: '-15', rcvd: '-15' },
{ name: 'FST4W', sent: '-15', rcvd: '-15' },
{ name: 'WSPR', sent: '-20', rcvd: '-20' },
{ name: 'RTTY', sent: '599', rcvd: '599' },
{ name: 'PSK31', sent: '599', rcvd: '599' },
{ name: 'PSK63', sent: '599', rcvd: '599' },
{ name: 'PSK125', sent: '599', rcvd: '599' },
{ name: 'OLIVIA', sent: '599', rcvd: '599' },
{ name: 'CONTESTI', sent: '599', rcvd: '599' },
{ name: 'MFSK', sent: '599', rcvd: '599' },
{ name: 'THROB', sent: '599', rcvd: '599' },
{ name: 'HELL', sent: '599', rcvd: '599' },
{ name: 'PACKET', sent: '599', rcvd: '599' },
{ name: 'PACTOR', sent: '599', rcvd: '599' },
{ name: 'VARA', sent: '599', rcvd: '599' },
{ name: 'VARA HF', sent: '599', rcvd: '599' },
{ name: 'ARDOP', sent: '599', rcvd: '599' },
{ name: 'ATV', sent: '59', rcvd: '59' },
{ name: 'SSTV', sent: '59', rcvd: '59' },
{ name: 'C4FM', sent: '59', rcvd: '59' },
{ name: 'DSTAR', sent: '59', rcvd: '59' },
{ name: 'DMR', sent: '59', rcvd: '59' },
{ name: 'FUSION', sent: '59', rcvd: '59' },
];
const emptyProfile = (): Profile => ({
id: 0,
name: '',
@@ -72,6 +123,7 @@ interface Props {
type SectionId =
| 'station'
| 'profiles'
| 'operating'
| 'lookup'
| 'lists-bands'
| 'lists-modes'
@@ -92,6 +144,7 @@ const TREE: TreeNode[] = [
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
{ kind: 'item', label: 'Station Information', id: 'station' },
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
{ kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' },
],
},
{
@@ -102,7 +155,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
]},
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'Backup / Export', id: 'backup', disabled: true },
{ kind: 'item', label: 'Database backup', id: 'backup' },
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
],
},
@@ -120,11 +173,12 @@ const TREE: TreeNode[] = [
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
station: 'Station Information',
profiles: 'Profiles',
operating: 'Operating conditions',
lookup: 'Callsign Lookup',
'lists-bands': 'Bands',
'lists-modes': 'Modes & default RST',
cluster: 'DX Cluster',
backup: 'Backup / Export',
backup: 'Database backup',
awards: 'Awards',
cat: 'CAT interface',
rotator: 'Rotator',
@@ -248,7 +302,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const updateActive = (patch: Partial<Profile>) =>
setActiveProfile((p) => (p ? { ...p, ...patch } : p));
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [] });
const [bandsText, setBandsText] = useState('');
// Custom band drafts (catalog covers ADIF spec but the user may have
// exotic or experimental bands not listed).
const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
@@ -259,6 +316,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
enabled: false, folder: '', rotation: 5, zip: false,
last_backup_at: '', default_folder: '',
} as any);
const [backupRunning, setBackupRunning] = useState(false);
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
@@ -281,14 +345,14 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
// click Connect/Disconnect inside the modal and see the pills change
// without saving + reopening.
useEffect(() => {
EventsOn('cluster:state', async (st: any) => {
const unsub = EventsOn('cluster:state', async (st: any) => {
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
try {
const list = await ListClusterServers();
setClusterServers((list ?? []) as ClusterServer[]);
} catch {}
});
return () => { EventsOff('cluster:state'); };
return () => { unsub?.(); };
}, []);
const [profiles, setProfiles] = useState<Profile[]>([]);
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
@@ -325,18 +389,18 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
useEffect(() => {
(async () => {
try {
const [l, ls, c, ap, r] = await Promise.all([
const [l, ls, c, ap, r, b] = await Promise.all([
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
GetRotatorSettings(),
GetRotatorSettings(), GetBackupSettings(),
]);
setLookup(l);
setActiveProfile(ap as Profile);
setLists(ls);
await reloadProfiles();
await reloadClusterServers();
setBandsText((ls.bands ?? []).join('\n'));
setCatCfg(c);
setRotator(r);
setBackupCfg(b as any);
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
@@ -345,12 +409,59 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
})();
}, []);
// ── Band selection helpers (dual-list shuttle) ──────────────────────────
function addBand(tag: string) {
const b = tag.trim().toLowerCase();
if (!b) return;
setLists((l) => {
if ((l.bands ?? []).includes(b)) return l;
return { ...l, bands: [...(l.bands ?? []), b] };
});
}
function removeBand(i: number) {
setLists((l) => {
const next = [...(l.bands ?? [])];
next.splice(i, 1);
return { ...l, bands: next };
});
}
function moveBand(i: number, dir: -1 | 1) {
setLists((l) => {
const next = [...(l.bands ?? [])];
const j = i + dir;
if (j < 0 || j >= next.length) return l;
[next[i], next[j]] = [next[j], next[i]];
return { ...l, bands: next };
});
}
// ── Mode helpers ────────────────────────────────────────────────────────
function addMode() {
setLists((l) => ({
...l,
modes: [...(l.modes ?? []), { name: '', default_rst_sent: '59', default_rst_rcvd: '59' }],
}));
}
function addModeFromCatalog(m: { name: string; sent: string; rcvd: string }) {
setLists((l) => {
if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === m.name)) return l;
return {
...l,
modes: [...(l.modes ?? []), { name: m.name, default_rst_sent: m.sent, default_rst_rcvd: m.rcvd }],
};
});
}
function addCustomMode(name: string) {
const n = name.trim().toUpperCase();
if (!n) return;
setLists((l) => {
if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === n)) return l;
return {
...l,
modes: [...(l.modes ?? []), { name: n, default_rst_sent: '59', default_rst_rcvd: '59' }],
};
});
}
function removeMode(i: number) {
setLists((l) => {
const next = [...(l.modes ?? [])];
@@ -378,11 +489,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
async function save() {
setSaving(true); setErr(''); setMsg('');
try {
// Bands: dedup, lowercase, trim.
// Bands: dedup, lowercase, trim. Order = user's drag order.
const seen = new Set<string>();
const bands: string[] = [];
for (const line of bandsText.split('\n')) {
const b = line.trim().toLowerCase();
for (const raw of lists.bands ?? []) {
const b = (raw ?? '').trim().toLowerCase();
if (b && !seen.has(b)) { seen.add(b); bands.push(b); }
}
const modes = (lists.modes ?? [])
@@ -407,6 +518,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
await SaveRotatorSettings(rotator as any);
await SaveBackupSettings(backupCfg as any);
await SetClusterAutoConnect(clusterAutoConnect);
setMsg('Settings saved.');
@@ -506,23 +618,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Label>POTA ref</Label>
<Input className="font-mono uppercase" value={p.my_pota_ref ?? ''} onChange={(e) => updateActive({ my_pota_ref: e.target.value })} placeholder="FF-1234" />
</div>
<div className="space-y-1">
<Label>Rig</Label>
<Input value={p.my_rig ?? ''} onChange={(e) => updateActive({ my_rig: e.target.value })} placeholder="Flex 8600" />
</div>
<div className="space-y-1">
<Label>Antenna</Label>
<Input value={p.my_antenna ?? ''} onChange={(e) => updateActive({ my_antenna: e.target.value })} placeholder="Ultrabeam UB40" />
</div>
<div className="space-y-1">
<Label>TX power (W)</Label>
<Input
type="number"
value={p.tx_pwr ?? ''}
onChange={(e) => updateActive({ tx_pwr: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) })}
placeholder="100"
/>
</div>
</div>
</>
);
@@ -796,57 +891,212 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
}
function BandsPanel() {
const selected = lists.bands ?? [];
const selectedSet = new Set(selected.map((b) => (b ?? '').toLowerCase()));
const available = BAND_CATALOG.filter((b) => !selectedSet.has(b.toLowerCase()));
return (
<>
<SectionHeader title="Bands" hint="One ADIF band per line (e.g. 20m, 2m, 70cm). Order = display order in the entry form and the band-slot grid." />
<Textarea
className="font-mono min-h-[260px] max-w-md"
value={bandsText}
onChange={(e) => setBandsText(e.target.value)}
<SectionHeader
title="Bands"
hint="Pick the bands you actually use. The entry strip, the band-slot grid and the band-map switcher only show what's on the right. Order on the right = display order."
/>
<div className="grid grid-cols-[1fr_auto_1fr] gap-3 max-w-3xl">
{/* Left: available catalog */}
<div className="rounded-md border border-border overflow-hidden">
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
Available
</div>
<div className="max-h-[320px] overflow-y-auto divide-y divide-border">
{available.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground italic">All catalog bands selected.</div>
) : (
available.map((b) => (
<button
key={b}
type="button"
onDoubleClick={() => addBand(b)}
onClick={() => addBand(b)}
className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors"
>
{b}
</button>
))
)}
</div>
<div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
<Input
value={bandDraft}
onChange={(e) => setBandDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
addBand(bandDraft);
setBandDraft('');
}
}}
placeholder="Custom band (e.g. 4m)"
className="font-mono h-7 text-xs"
/>
<Button
size="sm"
variant="outline"
className="h-7"
onClick={() => { addBand(bandDraft); setBandDraft(''); }}
disabled={!bandDraft.trim()}
>
<Plus className="size-3" />
</Button>
</div>
</div>
{/* Center: shuttle hint */}
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<ArrowRight className="size-4" />
<ArrowLeft className="size-4" />
</div>
{/* Right: selected */}
<div className="rounded-md border border-border overflow-hidden">
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground flex items-center justify-between">
<span>Selected ({selected.length})</span>
</div>
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
{selected.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground italic">
No band selected — pick from the left.
</div>
) : (
selected.map((b, i) => (
<div key={`${b}-${i}`} className="grid grid-cols-[auto_1fr_auto] items-center gap-1 px-2 py-1">
<div className="flex gap-0.5">
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, -1)} disabled={i === 0}>
<ArrowUp className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, 1)} disabled={i === selected.length - 1}>
<ArrowDown className="size-3" />
</Button>
</div>
<span className="font-mono text-sm">{b}</span>
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeBand(i)}>
<Trash2 className="size-3" />
</Button>
</div>
))
)}
</div>
</div>
</div>
</>
);
}
function ModesPanel() {
const selected = lists.modes ?? [];
const selectedSet = new Set(selected.map((m) => (m.name ?? '').toUpperCase()));
const available = MODE_CATALOG.filter((m) => !selectedSet.has(m.name));
return (
<>
<SectionHeader
title="Modes & default RST"
hint="When you pick a mode in the entry form, RST sent/rcvd auto-fill with these defaults (unless you've typed something)."
hint="Pick the modes you actually use on the right. Anywhere the UI shows a mode picker, it iterates the right column. When you select a mode in the entry form, RST sent/rcvd auto-fill with the defaults below (unless you've typed something)."
/>
<div className="rounded-md border border-border overflow-hidden max-w-2xl">
<div className="grid grid-cols-[auto_1fr_1fr_1fr_auto] gap-2 px-3 py-2 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
<span className="w-12">Order</span>
<span>Mode (ADIF)</span>
<span>RST sent</span>
<span>RST rcvd</span>
<span className="w-8"></span>
<div className="grid grid-cols-[1fr_auto_1.5fr] gap-3 max-w-4xl">
{/* Left: available catalog */}
<div className="rounded-md border border-border overflow-hidden">
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
Available
</div>
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
{available.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground italic">All catalog modes selected.</div>
) : (
available.map((m) => (
<button
key={m.name}
type="button"
onDoubleClick={() => addModeFromCatalog(m)}
onClick={() => addModeFromCatalog(m)}
className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors flex items-center justify-between gap-2"
title={`Default RST: ${m.sent} / ${m.rcvd}`}
>
<span>{m.name}</span>
<span className="text-[10px] text-muted-foreground">{m.sent}/{m.rcvd}</span>
</button>
))
)}
</div>
<div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
<Input
value={modeDraft}
onChange={(e) => setModeDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
addCustomMode(modeDraft);
setModeDraft('');
}
}}
placeholder="Custom mode"
className="font-mono uppercase h-7 text-xs"
/>
<Button
size="sm"
variant="outline"
className="h-7"
onClick={() => { addCustomMode(modeDraft); setModeDraft(''); }}
disabled={!modeDraft.trim()}
>
<Plus className="size-3" />
</Button>
</div>
</div>
<div className="divide-y divide-border">
{(lists.modes ?? []).map((m, i) => (
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_auto] gap-2 px-3 py-1.5 items-center">
<div className="flex gap-0.5 w-12">
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, -1)} disabled={i === 0}>
<ArrowUp className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === (lists.modes?.length ?? 0) - 1}>
<ArrowDown className="size-3" />
</Button>
{/* Center: shuttle hint */}
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<ArrowRight className="size-4" />
<ArrowLeft className="size-4" />
</div>
{/* Right: selected with editable RST */}
<div className="rounded-md border border-border overflow-hidden">
<div className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
<span className="w-12">Order</span>
<span>Mode</span>
<span>RST snt</span>
<span>RST rcv</span>
<span className="w-6"></span>
</div>
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
{selected.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground italic">
No mode selected — pick from the left.
</div>
<Input className="font-mono uppercase h-7" value={m.name} onChange={(e) => updateMode(i, { name: e.target.value })} placeholder="SSB" />
<Input className="font-mono h-7" value={m.default_rst_sent ?? ''} onChange={(e) => updateMode(i, { default_rst_sent: e.target.value })} placeholder="59" />
<Input className="font-mono h-7" value={m.default_rst_rcvd ?? ''} onChange={(e) => updateMode(i, { default_rst_rcvd: e.target.value })} placeholder="59" />
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeMode(i)}>
<Trash2 className="size-3" />
</Button>
</div>
))}
) : (
selected.map((m, i) => (
<div key={i} className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1 items-center">
<div className="flex gap-0.5 w-12">
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, -1)} disabled={i === 0}>
<ArrowUp className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === selected.length - 1}>
<ArrowDown className="size-3" />
</Button>
</div>
<Input className="font-mono uppercase h-7" value={m.name} onChange={(e) => updateMode(i, { name: e.target.value })} placeholder="SSB" />
<Input className="font-mono h-7" value={m.default_rst_sent ?? ''} onChange={(e) => updateMode(i, { default_rst_sent: e.target.value })} placeholder="59" />
<Input className="font-mono h-7" value={m.default_rst_rcvd ?? ''} onChange={(e) => updateMode(i, { default_rst_rcvd: e.target.value })} placeholder="59" />
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeMode(i)}>
<Trash2 className="size-3" />
</Button>
</div>
))
)}
</div>
<div className="px-3 py-1.5 border-t border-border bg-muted/40">
<Button variant="ghost" size="sm" onClick={addMode} className="h-6 text-xs">
<Plus className="size-3" /> Add blank row
</Button>
</div>
</div>
</div>
<Button variant="outline" size="sm" onClick={addMode} className="mt-3">
<Plus className="size-3.5" /> Add mode
</Button>
</>
);
}
@@ -1152,15 +1402,150 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
);
}
function OperatingPanelWrapper() {
return (
<>
<SectionHeader
title="Operating conditions"
hint="Define your rigs and the antennas you use on each band. The entry strip will auto-fill MY_RIG and MY_ANTENNA based on the default antenna for the band you're operating on."
/>
<OperatingPanel
bands={lists.bands ?? []}
onError={(m) => setErr(m)}
/>
</>
);
}
function BackupPanel() {
const fmtLast = (iso: string) => {
if (!iso) return 'never';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())} UTC`;
};
const effectiveFolder = (backupCfg.folder || backupCfg.default_folder || '').replace(/\\/g, '/');
async function backupNow() {
setBackupRunning(true); setBackupResult(null);
try {
// Save current draft first so the backup runs with the values
// the user just typed (folder, rotation, zip) — otherwise the
// backend would use stale persisted config.
await SaveBackupSettings(backupCfg as any);
const path = await RunBackupNow();
setBackupResult({ ok: true, msg: 'Backup written to ' + path });
const refreshed = await GetBackupSettings();
setBackupCfg(refreshed as any);
} catch (e: any) {
setBackupResult({ ok: false, msg: String(e?.message ?? e) });
} finally { setBackupRunning(false); }
}
return (
<>
<SectionHeader
title="Database backup"
hint="HamLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!backupCfg.enabled}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, enabled: !!c }))}
/>
<span>Automatic backup when closing HamLog (max once per day)</span>
</label>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Backup folder</Label>
<div className="flex gap-2">
<Input
className="font-mono text-xs flex-1"
placeholder={backupCfg.default_folder || 'leave empty for default'}
value={backupCfg.folder ?? ''}
onChange={(e) => setBackupCfg((b) => ({ ...b, folder: e.target.value }))}
/>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
const p = await PickBackupFolder();
if (p) setBackupCfg((b) => ({ ...b, folder: p }));
} catch (e: any) {
setBackupResult({ ok: false, msg: String(e?.message ?? e) });
}
}}
>
Browse…
</Button>
</div>
<div className="text-[10px] text-muted-foreground">
{backupCfg.folder
? <>Effective folder: <span className="font-mono">{effectiveFolder}</span></>
: <>If empty, HamLog uses the default: <span className="font-mono">{(backupCfg.default_folder ?? '').replace(/\\/g, '/')}</span></>
}
</div>
</div>
<div className="flex items-end gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Rotation (copies to keep)</Label>
<Input
type="number"
min={1}
max={365}
className="w-24 font-mono text-xs"
value={backupCfg.rotation || 5}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n) && n > 0) setBackupCfg((b) => ({ ...b, rotation: Math.floor(n) }));
}}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
<Checkbox
checked={!!backupCfg.zip}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, zip: !!c }))}
/>
<span>ZIP backup (smaller file)</span>
</label>
</div>
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
<Button size="sm" onClick={backupNow} disabled={backupRunning}>
{backupRunning ? 'Backing up…' : 'Backup now'}
</Button>
<span className="text-xs text-muted-foreground">
Last backup: <strong className="text-foreground">{fmtLast(backupCfg.last_backup_at)}</strong>
</span>
</div>
{backupResult && (
<div className={cn(
'text-xs px-3 py-2 rounded-md border',
backupResult.ok
? 'bg-emerald-50 border-emerald-300 text-emerald-800'
: 'bg-rose-50 border-rose-300 text-rose-800',
)}>
{backupResult.msg}
</div>
)}
</div>
</>
);
}
// Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = {
station: StationPanel,
profiles: ProfilesPanel,
operating: OperatingPanelWrapper,
lookup: LookupPanel,
'lists-bands': BandsPanel,
'lists-modes': ModesPanel,
cluster: ClusterPanel,
backup: () => <ComingSoon id="backup" icon={Database} />,
backup: BackupPanel,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
rotator: RotatorPanel,
@@ -1170,7 +1555,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-[960px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader>
<DialogTitle>Preferences</DialogTitle>
<DialogDescription className="sr-only">Configure HamLog modules — station, lookup, hardware…</DialogDescription>
@@ -1179,7 +1564,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
{loading ? (
<div className="p-6 text-muted-foreground">Loading…</div>
) : (
<div className="grid grid-cols-[260px_1fr] min-h-0 overflow-hidden">
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
{/* Left sidebar tree */}
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
<Tree selected={selected} onSelect={setSelected} />
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
import { EventsOn } from '../../wailsjs/runtime/runtime';
type Step = {
id: string;
label: string;
status: 'pending' | 'running' | 'done' | 'error';
detail?: string;
};
// ShutdownProgress is a full-screen overlay that appears while HamLog is
// running its close-time tasks (backup, future LoTW upload, ...). It
// listens for `shutdown:start` / `shutdown:update` / `shutdown:done`
// events from the backend and renders a checklist that updates as each
// task completes. The backend triggers wruntime.Quit() once everything
// is finished, so this component never has to dismiss itself.
export function ShutdownProgress() {
const [steps, setSteps] = useState<Step[] | null>(null);
useEffect(() => {
const u1 = EventsOn('shutdown:start', (s: Step[]) => setSteps(s ?? []));
const u2 = EventsOn('shutdown:update', (s: Step[]) => setSteps(s ?? []));
const u3 = EventsOn('shutdown:done', (s: Step[]) => setSteps(s ?? []));
return () => { u1?.(); u2?.(); u3?.(); };
}, []);
if (!steps) return null;
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-card border border-border rounded-lg shadow-xl p-6 min-w-[360px] max-w-[480px]">
<div className="text-sm font-semibold mb-3 text-foreground">Closing HamLog</div>
<div className="space-y-2">
{steps.length === 0 ? (
<div className="text-xs text-muted-foreground italic">Nothing to do, exiting.</div>
) : steps.map((s) => (
<div key={s.id} className="flex items-start gap-2 text-sm">
<div className="mt-0.5 w-4 flex items-center justify-center">
{s.status === 'done' && <CheckCircle2 className="size-4 text-emerald-600" />}
{s.status === 'running' && <Loader2 className="size-4 animate-spin text-primary" />}
{s.status === 'error' && <XCircle className="size-4 text-rose-600" />}
{s.status === 'pending' && <span className="size-2 rounded-full bg-muted-foreground/40" />}
</div>
<div className="flex-1">
<div className={
s.status === 'done' ? 'text-foreground'
: s.status === 'error' ? 'text-rose-700 font-medium'
: s.status === 'pending' ? 'text-muted-foreground'
: 'text-foreground font-medium'
}>
{s.label}
</div>
{s.detail && (
<div className="text-[11px] text-muted-foreground mt-0.5 font-mono break-all">
{s.detail}
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,297 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Columns3, Star } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView } from '@/types';
ModuleRegistry.registerModules([AllCommunityModule]);
const hamlogTheme = themeQuartz.withParams({
fontFamily: 'inherit',
fontSize: 12.5,
backgroundColor: '#faf6ea',
foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9',
headerTextColor: '#5a4f3a',
headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0',
rowHoverColor: '#ecdcb4',
selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994',
rowBorder: { color: '#d8c9a8', width: 1 },
columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10,
rowHeight: 30,
headerHeight: 32,
spacing: 4,
accentColor: '#b8410c',
iconSize: 12,
});
type WorkedEntry = NonNullable<WorkedBeforeView['entries']>[number];
type Props = {
wb: WorkedBeforeView | null;
busy: boolean;
currentCall: string;
};
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v1';
function fmtDateTime(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return '';
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())}`;
}
function fmtDate(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
const bandPill = (p: any) => p.value
? <span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
}}>{p.value}</span>
: '';
const modePill = (p: any) => p.value
? <span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
}}>{p.value}</span>
: '';
// Single Y/N flag column renderer: green dot for "Y", grey dash otherwise.
const flagRenderer = (p: any) => {
if (p.value === 'Y') {
return <span style={{
display: 'inline-block', width: 16, height: 16, borderRadius: 4,
backgroundColor: '#10b981', color: 'white', textAlign: 'center',
fontSize: 10, fontWeight: 700, lineHeight: '16px',
}}>Y</span>;
}
return <span style={{ color: '#a8a29e' }}></span>;
};
type ColEntry = ColDef<WorkedEntry> & { group: string; label: string; defaultVisible?: boolean };
const COL_CATALOG: ColEntry[] = [
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateTime(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'flex items-center', cellRenderer: bandPill, defaultVisible: true },
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'flex items-center', cellRenderer: modePill, defaultVisible: true },
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL S', field: 'qsl_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL R', field: 'qsl_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW S', field: 'lotw_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW R', field: 'lotw_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
];
const GROUP_ORDER = ['QSO', 'QSL', 'LoTW'];
export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const hasCall = currentCall.trim() !== '';
const count = wb?.count ?? 0;
const entries = wb?.entries ?? [];
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), []);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false,
}), []);
function onGridReady(e: GridReadyEvent) {
try {
const raw = localStorage.getItem(COL_STATE_KEY);
if (raw) {
const state = JSON.parse(raw) as ColumnState[];
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
}
} catch {}
}
const saveColumnState = useCallback(() => {
try {
const state = gridRef.current?.api?.getColumnState();
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
} catch {}
}, []);
function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId);
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
}
function setColVisible(colId: string, visible: boolean) {
const api = gridRef.current?.api;
if (!api) return;
api.setColumnsVisible([colId], visible);
saveColumnState();
}
function showAll(group?: string) {
const api = gridRef.current?.api;
if (!api) return;
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
api.setColumnsVisible(ids, true);
saveColumnState();
}
function hideAll(group?: string) {
const api = gridRef.current?.api;
if (!api) return;
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
api.setColumnsVisible(ids, false);
saveColumnState();
}
function resetDefaults() {
const api = gridRef.current?.api;
if (!api) return;
const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!);
const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!);
api.setColumnsVisible(visible, true);
api.setColumnsVisible(hidden, false);
saveColumnState();
}
// Empty / loading / no-call states.
if (!hasCall) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
Type a callsign in the entry strip to see prior contacts.
</div>
);
}
if (busy && count === 0) {
return (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
checking
</div>
);
}
if (count === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
<Star className="size-8 text-primary fill-current" />
<div className="text-2xl font-bold text-primary tracking-wider">NEW</div>
<div>No prior QSO with <span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>.</div>
</div>
);
}
return (
<>
<div className="flex items-center gap-3 px-3 py-1.5 border-b border-border/60 bg-muted/30">
<div className="flex items-baseline gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Worked before</span>
<span className="font-mono text-sm font-bold text-primary tracking-wider">{currentCall.toUpperCase()}</span>
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">{count}×</Badge>
</div>
<div className="text-[11px] text-muted-foreground">
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
</div>
{wb?.dxcc_name && (
<div className="text-[11px] text-muted-foreground">
DXCC: <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
{typeof wb.dxcc_count === 'number' && wb.dxcc_count > 0 && (
<span> · {wb.dxcc_count} entity QSOs</span>
)}
</div>
)}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> Columns
</Button>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<WorkedEntry>
ref={gridRef}
theme={hamlogTheme}
rowData={entries}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
onGridReady={onGridReady}
onColumnResized={saveColumnState}
onColumnMoved={saveColumnState}
onColumnPinned={saveColumnState}
onColumnVisible={saveColumnState}
onSortChanged={saveColumnState}
animateRows={false}
suppressCellFocus
getRowId={(p) => String((p.data as any).id)}
/>
</div>
</div>
{count > entries.length && (
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
+ {count - entries.length} older QSOs (not shown capped for performance)
</div>
)}
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Worked-before columns</DialogTitle>
<DialogDescription>
Pick the columns you want visible in the Worked-before table.
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto py-2">
{GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group);
if (cols.length === 0) return null;
return (
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
</div>
</div>
<div className="flex flex-col gap-1">
{cols.map((c) => (
<label key={c.colId} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
<Checkbox
checked={isColVisible(c.colId!)}
onCheckedChange={(v) => setColVisible(c.colId!, !!v)}
/>
{c.label}
</label>
))}
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+21
View File
@@ -6,6 +6,7 @@ import {profile} from '../models';
import {adif} from '../models';
import {cat} from '../models';
import {cluster} from '../models';
import {operating} from '../models';
import {lookup} from '../models';
export function ActivateProfile(arg1:number):Promise<void>;
@@ -26,6 +27,10 @@ export function DeleteAllQSO():Promise<number>;
export function DeleteClusterServer(arg1:number):Promise<void>;
export function DeleteOperatingAntenna(arg1:number):Promise<void>;
export function DeleteOperatingStation(arg1:number):Promise<void>;
export function DeleteProfile(arg1:number):Promise<void>;
export function DeleteQSO(arg1:number):Promise<void>;
@@ -40,6 +45,8 @@ export function ExportADIF(arg1:string):Promise<adif.ExportResult>;
export function GetActiveProfile():Promise<profile.Profile>;
export function GetBackupSettings():Promise<main.BackupSettings>;
export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>;
@@ -66,6 +73,8 @@ export function ImportADIF(arg1:string,arg2:boolean):Promise<adif.ImportResult>;
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
export function ListOperatingTree():Promise<Array<operating.Station>>;
export function ListProfiles():Promise<Array<profile.Profile>>;
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
@@ -76,6 +85,10 @@ export function OpenADIFFile():Promise<string>;
export function OpenExternalURL(arg1:string):Promise<void>;
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
export function PickBackupFolder():Promise<string>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
@@ -84,8 +97,12 @@ export function RotatorPark():Promise<void>;
export function RotatorStop():Promise<void>;
export function RunBackupNow():Promise<string>;
export function SaveADIFFile():Promise<string>;
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
@@ -94,6 +111,10 @@ export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
+40
View File
@@ -38,6 +38,14 @@ export function DeleteClusterServer(arg1) {
return window['go']['main']['App']['DeleteClusterServer'](arg1);
}
export function DeleteOperatingAntenna(arg1) {
return window['go']['main']['App']['DeleteOperatingAntenna'](arg1);
}
export function DeleteOperatingStation(arg1) {
return window['go']['main']['App']['DeleteOperatingStation'](arg1);
}
export function DeleteProfile(arg1) {
return window['go']['main']['App']['DeleteProfile'](arg1);
}
@@ -66,6 +74,10 @@ export function GetActiveProfile() {
return window['go']['main']['App']['GetActiveProfile']();
}
export function GetBackupSettings() {
return window['go']['main']['App']['GetBackupSettings']();
}
export function GetCATSettings() {
return window['go']['main']['App']['GetCATSettings']();
}
@@ -118,6 +130,10 @@ export function ListClusterServers() {
return window['go']['main']['App']['ListClusterServers']();
}
export function ListOperatingTree() {
return window['go']['main']['App']['ListOperatingTree']();
}
export function ListProfiles() {
return window['go']['main']['App']['ListProfiles']();
}
@@ -138,6 +154,14 @@ export function OpenExternalURL(arg1) {
return window['go']['main']['App']['OpenExternalURL'](arg1);
}
export function OperatingDefaultForBand(arg1) {
return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
}
export function PickBackupFolder() {
return window['go']['main']['App']['PickBackupFolder']();
}
export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat']();
}
@@ -154,10 +178,18 @@ export function RotatorStop() {
return window['go']['main']['App']['RotatorStop']();
}
export function RunBackupNow() {
return window['go']['main']['App']['RunBackupNow']();
}
export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile']();
}
export function SaveBackupSettings(arg1) {
return window['go']['main']['App']['SaveBackupSettings'](arg1);
}
export function SaveCATSettings(arg1) {
return window['go']['main']['App']['SaveCATSettings'](arg1);
}
@@ -174,6 +206,14 @@ export function SaveLookupSettings(arg1) {
return window['go']['main']['App']['SaveLookupSettings'](arg1);
}
export function SaveOperatingAntenna(arg1) {
return window['go']['main']['App']['SaveOperatingAntenna'](arg1);
}
export function SaveOperatingStation(arg1) {
return window['go']['main']['App']['SaveOperatingStation'](arg1);
}
export function SaveProfile(arg1) {
return window['go']['main']['App']['SaveProfile'](arg1);
}
+142
View File
@@ -257,6 +257,28 @@ export namespace lookup {
export namespace main {
export class BackupSettings {
enabled: boolean;
folder: string;
rotation: number;
zip: boolean;
last_backup_at: string;
default_folder: string;
static createFrom(source: any = {}) {
return new BackupSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.folder = source["folder"];
this.rotation = source["rotation"];
this.zip = source["zip"];
this.last_backup_at = source["last_backup_at"];
this.default_folder = source["default_folder"];
}
}
export class CATSettings {
enabled: boolean;
backend: string;
@@ -413,6 +435,7 @@ export namespace main {
country?: string;
continent?: string;
status: string;
worked_call: boolean;
static createFrom(source: any = {}) {
return new SpotStatus(source);
@@ -426,6 +449,7 @@ export namespace main {
this.country = source["country"];
this.continent = source["continent"];
this.status = source["status"];
this.worked_call = source["worked_call"];
}
}
export class StartupStatus {
@@ -469,6 +493,124 @@ export namespace main {
}
export namespace operating {
export class AntennaBand {
band: string;
is_default: boolean;
static createFrom(source: any = {}) {
return new AntennaBand(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.band = source["band"];
this.is_default = source["is_default"];
}
}
export class Antenna {
id: number;
station_id: number;
name: string;
sort_order: number;
bands: AntennaBand[];
static createFrom(source: any = {}) {
return new Antenna(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.station_id = source["station_id"];
this.name = source["name"];
this.sort_order = source["sort_order"];
this.bands = this.convertValues(source["bands"], AntennaBand);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class BandDefault {
station_id: number;
station_name: string;
antenna_id: number;
antenna_name: string;
tx_pwr?: number;
static createFrom(source: any = {}) {
return new BandDefault(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.station_id = source["station_id"];
this.station_name = source["station_name"];
this.antenna_id = source["antenna_id"];
this.antenna_name = source["antenna_name"];
this.tx_pwr = source["tx_pwr"];
}
}
export class Station {
id: number;
profile_id: number;
name: string;
tx_pwr?: number;
sort_order: number;
antennas?: Antenna[];
static createFrom(source: any = {}) {
return new Station(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.profile_id = source["profile_id"];
this.name = source["name"];
this.tx_pwr = source["tx_pwr"];
this.sort_order = source["sort_order"];
this.antennas = this.convertValues(source["antennas"], Antenna);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace profile {
export class Profile {