feat: Mainview can choose Map, cluster or worked before
This commit is contained in:
+280
-187
@@ -31,6 +31,7 @@ import {
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart,
|
||||
GetAwardDefs,
|
||||
GetUIPref,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||
@@ -49,7 +50,7 @@ import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { FirstRunModal } from '@/components/FirstRunModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { BandMap } from '@/components/BandMap';
|
||||
import { MainMap } from '@/components/MainMap';
|
||||
import { WorldMap, LocatorMap } from '@/components/MainMap';
|
||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
@@ -659,6 +660,30 @@ export default function App() {
|
||||
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';
|
||||
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('profile:changed', () => {
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk();
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
|
||||
});
|
||||
return () => { off(); };
|
||||
}, [loadStation, loadLists, loadCATCfg, reloadWk]);
|
||||
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await reloadWk();
|
||||
@@ -2243,6 +2268,243 @@ export default function App() {
|
||||
</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 (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
<ShutdownProgress />
|
||||
@@ -3022,60 +3284,15 @@ export default function App() {
|
||||
})}
|
||||
<div className="flex-1" />
|
||||
<Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
|
||||
{clusterFiltersToggleBtn}
|
||||
</div>
|
||||
|
||||
{/* Filters moved to the right-side panel (see below). */}
|
||||
|
||||
{(() => {
|
||||
// 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;
|
||||
}
|
||||
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());
|
||||
}
|
||||
// Filtered + grouped spots (shared with the Main-view cluster
|
||||
// pane). All the filter state lives in the right-side panel.
|
||||
const rendered = clusterRenderedRows;
|
||||
if (rendered.length === 0) {
|
||||
return (
|
||||
<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>{/* /left column */}
|
||||
|
||||
{/* Right-side filter panel (Log4OM style) */}
|
||||
<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 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filters</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>
|
||||
{/* Right-side filter panel (Log4OM style) — shared with the
|
||||
Main-view cluster pane; toggle hides it in both places. */}
|
||||
{clusterShowFilters && renderClusterFilters()}
|
||||
</TabsContent>
|
||||
|
||||
<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">
|
||||
<MainMap
|
||||
fromGrid={station.my_grid}
|
||||
toGrid={grid}
|
||||
fromLabel={station.callsign}
|
||||
toLabel={callsign}
|
||||
beamAzimuths={showBeamOnMap ? beamHeadings : []}
|
||||
/>
|
||||
{/* Two configurable panes (per-profile, Settings → Main view).
|
||||
Each side shows one of: great-circle map, locator map, cluster
|
||||
or worked-before. */}
|
||||
<div className="grid grid-cols-2 grid-rows-1 gap-2 h-full min-h-0 p-2">
|
||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
|
||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
|
||||
@@ -3443,6 +3535,7 @@ export default function App() {
|
||||
initialSection={settingsSection}
|
||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
|
||||
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user