rigs completed
This commit is contained in:
Generated
+57
@@ -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",
|
||||
|
||||
@@ -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 @@
|
||||
58f02c99f9fceb8f5aeae2c8b90fd325
|
||||
687705a933fcf09f20bdb5083955a417
|
||||
+170
-274
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Vendored
+21
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user