4 Commits

7 changed files with 486 additions and 337 deletions
+280 -187
View File
@@ -31,6 +31,7 @@ import {
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart,
GetAwardDefs, GetAwardDefs,
GetUIPref,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs'; import { applyAwardRefs } from '@/lib/awardRefs';
@@ -49,7 +50,7 @@ import { SettingsModal } from '@/components/SettingsModal';
import { FirstRunModal } from '@/components/FirstRunModal'; import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal'; import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap'; import { BandMap } from '@/components/BandMap';
import { MainMap } from '@/components/MainMap'; import { WorldMap, LocatorMap } from '@/components/MainMap';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder'; import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel'; import { AwardsPanel } from '@/components/AwardsPanel';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
@@ -659,6 +660,30 @@ export default function App() {
return next; return next;
}); });
}, []); }, []);
// Main tab is two configurable panes; each side shows one of the great-circle
// map ("map1"), the locator street map ("map2"), the cluster grid or the
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// so it's loaded async on mount and re-read on profile:changed below.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked';
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
const loadMainPanes = useCallback(async () => {
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked';
const [l, r] = await Promise.all([
GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''),
]);
setMainPaneLeft(valid(l) ? l : 'map1');
setMainPaneRight(valid(r) ? r : 'map2');
}, []);
useEffect(() => { loadMainPanes(); }, [loadMainPanes]);
// Cluster filter sidebar visibility — shared by the Cluster tab and the
// Main-view cluster pane (portable UI pref). Hiding it keeps the filters
// active, it just reclaims the width.
const [clusterShowFilters, setClusterShowFilters] = useState(() => localStorage.getItem('opslog.clusterShowFilters') !== '0');
const toggleClusterFilters = useCallback(() => {
setClusterShowFilters((v) => { const n = !v; writeUiPref('opslog.clusterShowFilters', n ? '1' : '0'); return n; });
}, []);
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source'; type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' }); const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked". // Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
@@ -1275,10 +1300,10 @@ export default function App() {
// side reloads its managers; this keeps the React state in sync. // side reloads its managers; this keeps the React state in sync.
useEffect(() => { useEffect(() => {
const off = EventsOn('profile:changed', () => { const off = EventsOn('profile:changed', () => {
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
}); });
return () => { off(); }; return () => { off(); };
}, [loadStation, loadLists, loadCATCfg, reloadWk]); }, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await reloadWk(); await reloadWk();
@@ -2243,6 +2268,243 @@ export default function App() {
</div> </div>
); );
// Cluster spots after every active filter (band / mode / status / search /
// hide-worked / group). Shared by the Cluster tab and the Main-view cluster
// pane so both show exactly the same list.
const clusterRenderedRows = useMemo(() => {
const bandsActive = clusterLockBand ? new Set([band]) : clusterBands;
const search = clusterSearch.trim().toUpperCase();
const 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;
}
if (clusterModeFilter.size > 0) {
const cat = spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz));
if (!cat || !clusterModeFilter.has(cat)) 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;
}
if (clusterHideWorked) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const e = spotStatus[k];
if (!e) return false;
if (e.worked_call || e.status === 'worked') 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());
}
return rendered;
}, [spots, clusterLockBand, band, clusterBands, clusterSearch, clusterFilterSource,
clusterLockMode, mode, clusterModeFilter, clusterStatusFilter, spotStatus,
clusterHideWorked, clusterGroup]);
// The Log4OM-style cluster filter sidebar (callsign search, hide-worked,
// group, band/mode/status/source). Rendered both in the Cluster tab and the
// Main-view cluster pane; toggled by clusterShowFilters.
const renderClusterFilters = () => (
<div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
<div className="px-2.5 py-2 border-b border-border/60 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filters</span>
<button type="button" onClick={toggleClusterFilters} title="Hide filters"
className="text-muted-foreground hover:text-foreground">
<X className="size-3.5" />
</button>
</div>
<div className="flex-1 overflow-auto p-2.5 space-y-3 text-xs">
{/* Callsign search */}
<Input
className="h-7 text-xs font-mono uppercase"
placeholder="Search call…"
value={clusterSearch}
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
/>
{/* Toggles */}
<div className="space-y-1.5">
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterHideWorked} onCheckedChange={(c) => setClusterHideWorked(!!c)} />
Hide worked
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
Group duplicates
</label>
</div>
{/* Band filter — multi-select listbox */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">Bands</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setClusterLockBand((v) => !v)}
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
clusterLockBand ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
title="Lock to the entry strip's current band"
>
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
</button>
{clusterBands.size > 0 && (
<button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">clear</button>
)}
</div>
</div>
<div className={cn('rounded border border-border max-h-36 overflow-auto bg-background', clusterLockBand && 'opacity-40 pointer-events-none')}>
{bands.map((b) => {
const on = clusterBands.has(b);
return (
<button
key={b}
type="button"
onClick={() => setClusterBands((s) => { const n = new Set(s); if (n.has(b)) n.delete(b); else n.add(b); return n; })}
className={cn('block w-full text-left px-2 py-0.5 font-mono text-[11px] border-b border-border/30 last:border-0',
on ? 'bg-primary text-primary-foreground' : 'hover:bg-accent/50')}
>
{b}
</button>
);
})}
</div>
</div>
{/* Mode lock */}
<button
type="button"
onClick={() => setClusterLockMode((v) => !v)}
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]',
clusterLockMode ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
title="Only show spots whose mode matches the entry strip"
>
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode})
</button>
{/* Status filter */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
<div className="flex flex-wrap gap-1">
{([
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
]).map((s) => {
const on = clusterStatusFilter.has(s.k);
return (
<button key={s.k} type="button"
onClick={() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
{s.label}
</button>
);
})}
</div>
</div>
{/* Mode filter */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
<div className="flex flex-wrap gap-1">
{([
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
]).map((s) => {
const on = clusterModeFilter.has(s.k);
return (
<button key={s.k} type="button"
onClick={() => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
{s.label}
</button>
);
})}
</div>
</div>
{/* Source */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Source</div>
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
<SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All sources</SelectItem>
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</div>
);
// A small "show filters" button shown when the sidebar is collapsed.
const clusterFiltersToggleBtn = (
<button type="button" onClick={toggleClusterFilters}
title={clusterShowFilters ? 'Hide filters' : 'Show filters'}
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium',
clusterShowFilters ? 'bg-primary text-primary-foreground border-primary' : 'text-muted-foreground border-border hover:bg-muted')}>
<SlidersHorizontal className="size-3" /> Filters
</button>
);
// Render one Main-view pane. The two sides (mainPaneLeft/Right) each pick from
// the same four choices, configured per-profile in Settings → Main view.
const renderMainPane = (kind: MainPaneKind) => {
switch (kind) {
case 'map1':
return (
<WorldMap
fromGrid={station.my_grid}
toGrid={grid}
fromLabel={station.callsign}
toLabel={callsign}
beamAzimuths={showBeamOnMap ? beamHeadings : []}
/>
);
case 'map2':
return <LocatorMap toGrid={grid} toLabel={callsign} />;
case 'cluster':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-2 py-1 border-b border-border/60 shrink-0">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Cluster</span>
{clusterFiltersToggleBtn}
</div>
<div className="flex-1 min-h-0 flex">
<div className="flex-1 min-w-0 flex flex-col min-h-0">
<ClusterGrid rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
</div>
{clusterShowFilters && renderClusterFilters()}
</div>
</div>
);
case 'worked':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
</div>
);
}
};
return ( return (
<div className="flex flex-col h-screen bg-background"> <div className="flex flex-col h-screen bg-background">
<ShutdownProgress /> <ShutdownProgress />
@@ -3022,60 +3284,15 @@ export default function App() {
})} })}
<div className="flex-1" /> <div className="flex-1" />
<Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge> <Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
{clusterFiltersToggleBtn}
</div> </div>
{/* Filters moved to the right-side panel (see below). */} {/* Filters moved to the right-side panel (see below). */}
{(() => { {(() => {
// Apply every filter. `bandsActive` is the band set the // Filtered + grouped spots (shared with the Main-view cluster
// user clicked, OR the entry's locked band when Lock band // pane). All the filter state lives in the right-side panel.
// is on. Mode lock compares the spot's inferred mode to const rendered = clusterRenderedRows;
// 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;
}
if (clusterModeFilter.size > 0) {
const cat = spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz));
if (!cat || !clusterModeFilter.has(cat)) 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;
}
// Hide worked: drop spots whose exact call is already worked,
// or whose entity+band+mode slot is already in the log. The
// status is resolved asynchronously, so we also hide spots
// whose status isn't known yet — otherwise a worked spot would
// flash in (no status) then vanish once it resolves. A new
// spot waits for its status, then appears only if not worked.
if (clusterHideWorked) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const e = spotStatus[k];
if (!e) return false;
if (e.worked_call || e.status === 'worked') 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) { if (rendered.length === 0) {
return ( return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12"> <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
@@ -3131,134 +3348,9 @@ export default function App() {
</div> </div>
</div>{/* /left column */} </div>{/* /left column */}
{/* Right-side filter panel (Log4OM style) */} {/* Right-side filter panel (Log4OM style) — shared with the
<div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10"> Main-view cluster pane; toggle hides it in both places. */}
<div className="px-2.5 py-2 border-b border-border/60 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filters</div> {clusterShowFilters && renderClusterFilters()}
<div className="flex-1 overflow-auto p-2.5 space-y-3 text-xs">
{/* Callsign search */}
<Input
className="h-7 text-xs font-mono uppercase"
placeholder="Search call…"
value={clusterSearch}
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
/>
{/* Toggles */}
<div className="space-y-1.5">
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterHideWorked} onCheckedChange={(c) => setClusterHideWorked(!!c)} />
Hide worked
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
Group duplicates
</label>
</div>
{/* Band filter — multi-select listbox */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">Bands</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setClusterLockBand((v) => !v)}
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
clusterLockBand ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
title="Lock to the entry strip's current band"
>
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
</button>
{clusterBands.size > 0 && (
<button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">clear</button>
)}
</div>
</div>
<div className={cn('rounded border border-border max-h-36 overflow-auto bg-background', clusterLockBand && 'opacity-40 pointer-events-none')}>
{bands.map((b) => {
const on = clusterBands.has(b);
return (
<button
key={b}
type="button"
onClick={() => setClusterBands((s) => { const n = new Set(s); if (n.has(b)) n.delete(b); else n.add(b); return n; })}
className={cn('block w-full text-left px-2 py-0.5 font-mono text-[11px] border-b border-border/30 last:border-0',
on ? 'bg-primary text-primary-foreground' : 'hover:bg-accent/50')}
>
{b}
</button>
);
})}
</div>
</div>
{/* Mode lock */}
<button
type="button"
onClick={() => setClusterLockMode((v) => !v)}
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]',
clusterLockMode ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
title="Only show spots whose mode matches the entry strip"
>
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode})
</button>
{/* Status filter */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
<div className="flex flex-wrap gap-1">
{([
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
]).map((s) => {
const on = clusterStatusFilter.has(s.k);
return (
<button key={s.k} type="button"
onClick={() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
{s.label}
</button>
);
})}
</div>
</div>
{/* Mode filter */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
<div className="flex flex-wrap gap-1">
{([
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
]).map((s) => {
const on = clusterModeFilter.has(s.k);
return (
<button key={s.k} type="button"
onClick={() => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
{s.label}
</button>
);
})}
</div>
</div>
{/* Source */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Source</div>
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
<SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All sources</SelectItem>
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</div>
</TabsContent> </TabsContent>
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1"> <TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
@@ -3277,13 +3369,13 @@ export default function App() {
)} )}
<TabsContent value="main" className="flex-1 min-h-0 p-0"> <TabsContent value="main" className="flex-1 min-h-0 p-0">
<MainMap {/* Two configurable panes (per-profile, Settings → Main view).
fromGrid={station.my_grid} Each side shows one of: great-circle map, locator map, cluster
toGrid={grid} or worked-before. */}
fromLabel={station.callsign} <div className="grid grid-cols-2 grid-rows-1 gap-2 h-full min-h-0 p-2">
toLabel={callsign} <div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
beamAzimuths={showBeamOnMap ? beamHeadings : []} <div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
/> </div>
</TabsContent> </TabsContent>
<TabsContent value="awards" className="flex-1 min-h-0 p-0"> <TabsContent value="awards" className="flex-1 min-h-0 p-0">
@@ -3443,6 +3535,7 @@ export default function App() {
initialSection={settingsSection} initialSection={settingsSection}
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }} onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }} onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
/> />
)} )}
+97 -99
View File
@@ -14,22 +14,15 @@ function saveMapView(m: L.Map) {
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() })); writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
} }
// MainMap — Log4OM-style dual map for the Main tab: // The Main tab is built from two independent map panes that the operator can
// • Left: a world map with the great-circle path drawn from the operator to // place on either side (Settings → Main view):
// the contacted station, plus distance + short/long-path azimuth. // • WorldMap ("map1"): a world map with the great-circle path from the
// • Right: a street map zoomed onto the contacted station's grid locator. // operator to the contacted station, distance, short/long-path azimuth and
// the antenna beam lobe.
// • LocatorMap ("map2"): a street map zoomed onto the contacted station's grid.
// Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use // Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use
// circleMarkers / divIcons so we don't depend on Leaflet's image assets. // circleMarkers / divIcons so we don't depend on Leaflet's image assets.
interface Props {
fromGrid: string; // operator grid (active profile)
toGrid: string; // contacted-station grid
fromLabel?: string; // operator callsign
toLabel?: string; // DX callsign
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30
}
// unwrapLon makes a lat/lon ring continuous in longitude (each point within // unwrapLon makes a lat/lon ring continuous in longitude (each point within
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap // 180° of the previous) so a polygon crossing the antimeridian doesn't snap
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine. // across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
@@ -62,14 +55,20 @@ function dot(color: string): L.DivIcon {
}); });
} }
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: Props) { interface WorldProps {
fromGrid: string; // operator grid (active profile)
toGrid: string; // contacted-station grid
fromLabel?: string; // operator callsign
toLabel?: string; // DX callsign
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30
}
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: WorldProps) {
const worldRef = useRef<HTMLDivElement>(null); const worldRef = useRef<HTMLDivElement>(null);
const locatorRef = useRef<HTMLDivElement>(null);
const worldMap = useRef<L.Map | null>(null); const worldMap = useRef<L.Map | null>(null);
const locatorMap = useRef<L.Map | null>(null);
// Layers we add/remove as the QSO changes (kept separate from the basemap).
const worldOverlay = useRef<L.LayerGroup | null>(null); const worldOverlay = useRef<L.LayerGroup | null>(null);
const locatorOverlay = useRef<L.LayerGroup | null>(null);
// Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator // Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator
// pans/zooms freely (e.g. a whole-world view) and the view is remembered // pans/zooms freely (e.g. a whole-world view) and the view is remembered
@@ -86,97 +85,70 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m); L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m);
worldOverlay.current = L.layerGroup().addTo(m); worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m; worldMap.current = m;
// Restore the saved free-pan view when not auto-zooming.
const sv = loadMapView(); const sv = loadMapView();
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom); if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
// Remember the view as the user pans/zooms (only meaningful when free).
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); }); m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
} }
if (locatorRef.current && !locatorMap.current) { const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
.setView([20, 0], 2);
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
locatorOverlay.current = L.layerGroup().addTo(m);
locatorMap.current = m;
}
// The Main tab may have just become visible — fix tile sizing.
const t = window.setTimeout(() => {
worldMap.current?.invalidateSize();
locatorMap.current?.invalidateSize();
}, 80);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, []); }, []);
// Redraw overlays whenever the operator/DX grids change. // Redraw overlays whenever the operator/DX grids (or beam) change.
useEffect(() => { useEffect(() => {
const wm = worldMap.current, lm = locatorMap.current; const wm = worldMap.current, wo = worldOverlay.current;
const wo = worldOverlay.current, lo = locatorOverlay.current; if (!wm || !wo) return;
if (!wm || !lm || !wo || !lo) return;
wo.clearLayers(); wo.clearLayers();
lo.clearLayers();
const from = gridToLatLon(fromGrid); const from = gridToLatLon(fromGrid);
const to = gridToLatLon(toGrid); const to = gridToLatLon(toGrid);
if (from && to) {
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo);
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo);
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 96);
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
{ color: '#2563eb', weight: 2, opacity: 0.8 }).addTo(wo);
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ── // ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (from && beamAzimuths && beamAzimuths.length) { if (beamAzimuths && beamAzimuths.length) {
const half = (beamWidth ?? 30) / 2; const half = (beamWidth ?? 30) / 2;
const D = 5500; // lobe length (km) const D = 5500; // lobe length (km)
// A great circle pointing poleward runs to lat ±90, where Mercator is // Great-circle radial out to distance D, stopping just short of the pole
// infinite — the line then snaps across the top of the map. Generate the // so a poleward line doesn't snap across the top of the Mercator map.
// radial with plenty of points (smooth curve) and STOP it just before the
// pole, so a north/south beam draws a clean line toward the edge instead.
const radial = (b: number): [number, number][] => { const radial = (b: number): [number, number][] => {
const pts: [number, number][] = []; const out: [number, number][] = [];
const N = 64; const N = 64;
for (let i = 1; i <= N; i++) { for (let i = 1; i <= N; i++) {
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N); const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
pts.push([d.lat, d.lon]); out.push([d.lat, d.lon]);
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
} }
return pts; return out;
}; };
for (const az of beamAzimuths) { for (const az of beamAzimuths) {
const arc: [number, number][] = []; // Draw the lobe as a FAN of translucent great-circle radials, not a
for (let b = az - half; b <= az + half + 0.001; b += 2) { // filled polygon: a polygon breaks badly near the poles on Mercator
const d = destinationPoint(from.lat, from.lon, b, D); // (its edges run off toward ±90° and the fill smears across the map),
arc.push([d.lat, d.lon]); // while each radial LINE stays clean. The overlapping lines read as a
// lobe — solid near the antenna, fanning out toward the front. Works
// for any azimuth, north/south included.
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
L.polyline(line as L.LatLngExpression[], { color: '#dc2626', weight: 6, opacity: 0.07 }).addTo(wo);
} }
const ring = unwrapLon([
[from.lat, from.lon],
...radial(az - half),
...arc,
...radial(az + half).reverse(),
]);
// Near a pole the lobe's two edges diverge wildly (one runs NW, the
// other NE) and look broken on a Mercator map — so for a poleward beam
// show ONLY the clean boresight (below). Otherwise draw the filled lobe.
if (!ring.some(([la]) => Math.abs(la) > 78)) {
L.polygon(ring as L.LatLngExpression[], {
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
}).addTo(wo);
}
// Boresight (dashed centre line) — always; great-circle polyline is safe.
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]); const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' }) L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo); .bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
} }
} }
// ── Left: world + great-circle arc ──
if (from) L.marker([from.lat, from.lon], { icon: dot('#059669'), title: fromLabel || 'Home' }).addTo(wo);
if (to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(toLabel || toGrid, { permanent: false, direction: 'top' }).addTo(wo);
}
if (from && to) {
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 96);
L.polyline(pts as L.LatLngExpression[], { color: '#dc2626', weight: 2, opacity: 0.9 }).addTo(wo);
// Only re-frame the map when auto-zoom is on; otherwise keep the user's
// chosen (remembered) view so the beam heading stays visible.
if (autoZoom) { if (autoZoom) {
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]); const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); // include the arc pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 }); wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
} }
} else if (autoZoom && to) { } else if (autoZoom && to) {
@@ -184,30 +156,14 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
} else if (autoZoom && from) { } else if (autoZoom && from) {
wm.setView([from.lat, from.lon], 3); wm.setView([from.lat, from.lon], 3);
} }
setTimeout(() => { wm.invalidateSize(); }, 0);
// ── Right: street map on the DX locator ──
if (to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(lo);
const b = gridSquareBounds(toGrid);
if (b) {
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
}
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
} else if (from) {
lm.setView([from.lat, from.lon], 5);
}
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]); }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]);
const path = pathBetween(fromGrid, toGrid); const path = pathBetween(fromGrid, toGrid);
return ( return (
<div className="flex flex-col h-full min-h-0 gap-2 p-2"> <div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
<div className="grid grid-cols-2 gap-2 flex-1 min-h-0">
<div className="relative isolate rounded-lg overflow-hidden border border-border">
<div ref={worldRef} className="absolute inset-0" /> <div ref={worldRef} className="absolute inset-0" />
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom {/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
(remembered across restarts), so the beam heading stays visible. */} (remembered across restarts), so the beam heading stays visible. */}
@@ -218,8 +174,7 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
setAutoZoom(v); setAutoZoom(v);
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0'); writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
const m = worldMap.current; const m = worldMap.current;
if (!v && m) saveMapView(m); // entering free mode → remember current view if (!v && m) saveMapView(m);
// (turning it on re-frames via the redraw effect, which depends on autoZoom)
}} }}
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'} title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${ className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
@@ -237,7 +192,52 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
</div> </div>
)} )}
</div> </div>
<div className="relative isolate rounded-lg overflow-hidden border border-border"> );
}
interface LocatorProps {
toGrid: string; // contacted-station grid
toLabel?: string; // DX callsign
}
// LocatorMap — street map zoomed onto the DX grid, the "map2" pane.
export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
const locatorRef = useRef<HTMLDivElement>(null);
const locatorMap = useRef<L.Map | null>(null);
const locatorOverlay = useRef<L.LayerGroup | null>(null);
useEffect(() => {
if (locatorRef.current && !locatorMap.current) {
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
.setView([20, 0], 2);
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
locatorOverlay.current = L.layerGroup().addTo(m);
locatorMap.current = m;
}
const t = window.setTimeout(() => { locatorMap.current?.invalidateSize(); }, 80);
return () => window.clearTimeout(t);
}, []);
useEffect(() => {
const lm = locatorMap.current, lo = locatorOverlay.current;
if (!lm || !lo) return;
lo.clearLayers();
const to = gridToLatLon(toGrid);
if (to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(lo);
const b = gridSquareBounds(toGrid);
if (b) {
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
}
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
}
setTimeout(() => { lm.invalidateSize(); }, 0);
}, [toGrid, toLabel]);
return (
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
<div ref={locatorRef} className="absolute inset-0" /> <div ref={locatorRef} className="absolute inset-0" />
{!gridToLatLon(toGrid) && ( {!gridToLatLon(toGrid) && (
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none"> <div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
@@ -245,7 +245,5 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
</div> </div>
)} )}
</div> </div>
</div>
</div>
); );
} }
+58 -1
View File
@@ -34,6 +34,7 @@ import {
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
ComputeStationInfo, ComputeStationInfo,
GetUIPref, SetUIPref,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -136,6 +137,7 @@ interface Props {
initialSection?: string; initialSection?: string;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
} }
// Pretty little card showing what OpsLog will stamp on each QSO based on // Pretty little card showing what OpsLog will stamp on each QSO based on
@@ -445,6 +447,59 @@ function TelemetryToggle() {
); );
} }
// MainViewPanes lets the operator choose what the Main tab's left and right
// panes show, independently: the great-circle map, the locator street map, the
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
{ value: 'map1', label: 'Map — great-circle + beam' },
{ value: 'map2', label: 'Map — locator (street)' },
{ value: 'cluster', label: 'Cluster spots' },
{ value: 'worked', label: 'Worked before' },
];
function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', value: string) => void }) {
const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2');
useEffect(() => {
const valid = (v: string) => MAIN_PANE_OPTIONS.some((o) => o.value === v);
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
}, []);
const pick = (side: 'left' | 'right', v: string) => {
if (side === 'left') setLeft(v); else setRight(v);
// Persist (per-profile) AND tell the parent the new value directly, so the
// Main view updates from the chosen value — never a stale DB re-read.
SetUIPref(side === 'left' ? 'mainPaneLeft' : 'mainPaneRight', v).catch(() => {});
onChanged?.(side, v);
};
return (
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
<div className="grid grid-cols-2 gap-3 max-w-xl">
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">Left pane</span>
<Select value={left} onValueChange={(v) => pick('left', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{MAIN_PANE_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">Right pane</span>
<Select value={right} onValueChange={(v) => pick('right', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{MAIN_PANE_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
</div>
</div>
);
}
// FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one // FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one
// (fills the IP/port). Self-contained so it can own its state (rendered inside // (fills the IP/port). Self-contained so it can own its state (rendered inside
// the hook-less CATPanel). // the hook-less CATPanel).
@@ -495,7 +550,7 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
); );
} }
export function SettingsModal({ onClose, onSaved, initialSection }: Props) { export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged }: Props) {
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station'); const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -3292,6 +3347,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</label> </label>
<TelemetryToggle /> <TelemetryToggle />
<MainViewPanes onChanged={onMainPaneChanged} />
<div className="border-t border-border/60 pt-4 space-y-2"> <div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4> <h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
{secret.has_passphrase ? ( {secret.has_passphrase ? (
+1
View File
@@ -22,6 +22,7 @@ const PORTABLE_KEYS = [
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom) 'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom) 'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing 'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
]; ];
// syncPortablePrefs reconciles the DB with the local cache at startup: // syncPortablePrefs reconciles the DB with the local cache at startup:
+3 -3
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.11'; export const APP_VERSION = '0.11.1';
// Author / credits, shown in Help → About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2 -2
View File
@@ -214,8 +214,8 @@ func TestNormalize(t *testing.T) {
"f4bpo": "F4BPO", "f4bpo": "F4BPO",
" F4BPO ": "F4BPO", " F4BPO ": "F4BPO",
"F4BPO/P": "F4BPO", "F4BPO/P": "F4BPO",
"F4BPO/MM": "", // maritime mobile → no DXCC entity "F4BPO/MM": "F4BPO", // maritime mobile → strip, keep home entity for the log
"F4BPO/AM": "", // aeronautical mobile → no DXCC entity "F4BPO/AM": "F4BPO", // aeronautical mobile → strip, keep home entity for the log
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity "F4BPO/M": "F4BPO", // plain mobile keeps the home entity
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5 "F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8) "HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
+4 -4
View File
@@ -1,4 +1,4 @@
package main package main
import ( import (
"bytes" "bytes"
@@ -13,15 +13,15 @@ import (
"hamlog/internal/applog" "hamlog/internal/applog"
) )
// Anonymous usage telemetry — a once-a-day "app_opened" heartbeat to PostHog so // Anonymous usage telemetry - a once-a-day "app_opened" heartbeat to PostHog so
// the OpsLog author can see how many people actively use it. Privacy by design: // the OpsLog author can see how many people actively use it. Privacy by design:
// only a random install ID + app version + OS are sent (no callsign, no QSO // only a random install ID + app version + OS are sent (no callsign, no QSO
// data, no IP beyond what any HTTP request reveals). Users can disable it in // data, no IP beyond what any HTTP request reveals). Users can disable it in
// Preferences → General. See [[user-analytics-posthog]] notes in MEMORY. // Preferences -> General. See [[user-analytics-posthog]] notes in MEMORY.
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.11" appVersion = "0.11.1"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.