feat: Mainview can choose Map, cluster or worked before

This commit is contained in:
2026-06-16 21:04:19 +02:00
parent 3d15f20c7f
commit a7bbc53c35
4 changed files with 477 additions and 317 deletions
+280 -187
View File
@@ -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); }}
/>
)}