refactor(cluster): one rule for the filter panel
The panel had grown two shapes for the same kind of choice. Some filters were
checkboxes, some were chips, and New counties only was a checkbox duplicating a
chip - which I added, and which was the worst of it: a control that exists twice
is not more discoverable, it is one control the operator has to recognise twice.
The rule now:
SWITCH a behaviour that is on or off, and narrows nothing by a property of
the station: hide worked, group duplicates, the two display options,
LoTW users only.
CHIPS pick any number from a set; none picked means all. Status, mode,
spotter continent. Selected is solid, unselected is the same chip
faded, so the palette keeps teaching the colour code while switched
off.
Nothing appears in both shapes. The duplicate county checkbox is gone.
Spotter continent became a chip row: seven two-letter codes fit on two lines,
they read as a set the way Status and Mode do, and several can be picked at
once - which "EU or NA" needs and a dropdown cannot express.
Both Lock buttons now sit in the heading of the section they lock, instead of
floating between sections, and every multi-select section clears the same way
through the same heading slot. One section helper and one chip helper, so the
next filter cannot drift.
The panel was also entirely hardcoded English - Search call, Hide worked, Bands,
Status - against the project's own bilingual rule. All of it goes through t()
now, both locales.
This commit is contained in:
+117
-127
@@ -1495,7 +1495,7 @@ export default function App() {
|
||||
// of the station rather than by what the spot is worth, which is why they sit
|
||||
// beside Hide worked and not among the status chips.
|
||||
const [clusterLotwOnly, setClusterLotwOnly] = useState(() => localStorage.getItem('opslog.clusterLotwOnly') === '1');
|
||||
const [clusterSpotterCont, setClusterSpotterCont] = useState(() => localStorage.getItem('opslog.clusterSpotterCont') ?? '');
|
||||
const [clusterSpotterConts, setClusterSpotterConts] = useState<Set<string>>(() => lsSet<string>('opslog.clusterSpotterCont'));
|
||||
const [clusterMuteWorked, setClusterMuteWorked] = useState(() => localStorage.getItem('opslog.clusterMuteWorked') === '1');
|
||||
const [clusterSlotHighlight, setClusterSlotHighlight] = useState(() => localStorage.getItem('opslog.clusterSlotHighlight') === '1');
|
||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
||||
@@ -1516,11 +1516,13 @@ export default function App() {
|
||||
writeUiPref('opslog.clusterLockBand', clusterLockBand ? '1' : '0');
|
||||
writeUiPref('opslog.clusterLockMode', clusterLockMode ? '1' : '0');
|
||||
writeUiPref('opslog.clusterStatusFilter', JSON.stringify([...clusterStatusFilter]));
|
||||
writeUiPref('opslog.clusterSpotterCont', JSON.stringify([...clusterSpotterConts]));
|
||||
writeUiPref('opslog.clusterModeFilter', JSON.stringify([...clusterModeFilter]));
|
||||
writeUiPref('opslog.clusterSearch', clusterSearch);
|
||||
writeUiPref('opslog.clusterHideWorked', clusterHideWorked ? '1' : '0');
|
||||
}, [clusterFilterSource, clusterGroup, clusterBands, clusterLockBand, clusterLockMode,
|
||||
clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked]);
|
||||
clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked,
|
||||
clusterLotwOnly, clusterSpotterConts]);
|
||||
// Bands shown side-by-side in the Band Map tab (portable).
|
||||
const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
|
||||
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
|
||||
@@ -4717,14 +4719,14 @@ export default function App() {
|
||||
// LoTW only, and the spotter's continent. Both are properties of the
|
||||
// station rather than judgements about the spot, so they AND with the
|
||||
// status chips instead of joining that OR: "a new band, and from Europe".
|
||||
if (clusterLotwOnly || clusterSpotterCont) {
|
||||
if (clusterLotwOnly || clusterSpotterConts.size > 0) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const e = spotStatus[k];
|
||||
// An unresolved spot is not filtered out. The status arrives a moment
|
||||
// after the row does, and dropping it meanwhile made the list flicker.
|
||||
if (e) {
|
||||
if (clusterLotwOnly && !e.lotw) return false;
|
||||
if (clusterSpotterCont && e.spotter_continent && e.spotter_continent !== clusterSpotterCont) return false;
|
||||
if (clusterSpotterConts.size > 0 && e.spotter_continent && !clusterSpotterConts.has(e.spotter_continent)) return false;
|
||||
}
|
||||
}
|
||||
if (clusterHideWorked) {
|
||||
@@ -4753,11 +4755,46 @@ export default function App() {
|
||||
// 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.
|
||||
// One rule for the whole panel, because two shapes for the same kind of choice
|
||||
// is what made it unreadable:
|
||||
//
|
||||
// SWITCH — a behaviour that is on or off (hide worked, group duplicates).
|
||||
// CHIPS — pick any number from a set; none picked means all (status, mode,
|
||||
// continent). Selected is solid, unselected is the same chip faded,
|
||||
// so the palette teaches the colour code even while switched off.
|
||||
//
|
||||
// Nothing appears in both shapes. A control that exists twice is not more
|
||||
// discoverable, it is one control the operator has to recognise twice.
|
||||
const fSection = (label: string, children: any, right?: any) => (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1 min-h-[16px]">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const fSwitch = (label: string, on: boolean, set: (v: boolean) => void) => (
|
||||
<label className="flex items-center gap-2 cursor-pointer rounded px-1 py-1 -mx-1 hover:bg-accent/40">
|
||||
<Checkbox checked={on} onCheckedChange={(c) => set(!!c)} />
|
||||
<span className="leading-none">{label}</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
const F_CHIP = 'px-1.5 py-[3px] rounded-md border text-[10px] font-bold tracking-wider transition-opacity';
|
||||
const fChip = (key: string, label: string, cls: string, on: boolean, toggle: () => void) => (
|
||||
<button key={key} type="button" onClick={toggle}
|
||||
className={cn(F_CHIP, on ? cls : `${cls} opacity-40 hover:opacity-80`)}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
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"
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('clu.filters')}</span>
|
||||
<button type="button" onClick={toggleClusterFilters} title={t('clu.hideFilters')}
|
||||
className="text-muted-foreground hover:text-foreground">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
@@ -4766,89 +4803,48 @@ export default function App() {
|
||||
{/* Callsign search */}
|
||||
<Input
|
||||
className="h-7 text-xs font-mono uppercase"
|
||||
placeholder="Search call…"
|
||||
placeholder={t('clu.searchCall')}
|
||||
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>
|
||||
{/* Two ways to cut through a busy cluster, and they compose: mute what
|
||||
is done, light up what is not. They sit HERE, with the other things
|
||||
an operator changes mid-run, and not in the preferences dialog.
|
||||
Both drive the band map as well, through lib/spotDisplay. */}
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={clusterMuteWorked}
|
||||
onCheckedChange={(c) => { const v = !!c; setClusterMuteWorked(v); writeUiPref('opslog.clusterMuteWorked', v ? '1' : '0'); }} />
|
||||
{t('clu.muteWorkedShort')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={clusterSlotHighlight}
|
||||
onCheckedChange={(c) => { const v = !!c; setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); }} />
|
||||
{t('clu.slotHighlightShort')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={clusterLotwOnly}
|
||||
onCheckedChange={(c) => { const v = !!c; setClusterLotwOnly(v); writeUiPref('opslog.clusterLotwOnly', v ? '1' : '0'); }} />
|
||||
{t('clu.lotwOnly')}
|
||||
</label>
|
||||
{/* The same state as the NEW COUNTY status chip, reached a second way.
|
||||
Not a second filter: county chasing is a mode you switch into, and
|
||||
hunting for one chip among eight is not how you switch into it. */}
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={clusterStatusFilter.has('new-county')}
|
||||
onCheckedChange={(c) => setClusterStatusFilter((cur) => {
|
||||
const n = new Set(cur);
|
||||
if (c) n.add('new-county'); else n.delete('new-county');
|
||||
return n;
|
||||
})} />
|
||||
{t('clu.newCountyOnly')}
|
||||
</label>
|
||||
{/* Behaviours: each is on or off, and none of them narrows by a property
|
||||
of the station. The last two also drive the band map, via
|
||||
lib/spotDisplay — they live here rather than in Preferences because
|
||||
they are changed while working a run. */}
|
||||
<div className="space-y-0.5">
|
||||
{fSwitch(t('clu.hideWorked'), clusterHideWorked, setClusterHideWorked)}
|
||||
{fSwitch(t('clu.groupDup'), clusterGroup, setClusterGroup)}
|
||||
{fSwitch(t('clu.muteWorkedShort'), clusterMuteWorked, (v) => { setClusterMuteWorked(v); writeUiPref('opslog.clusterMuteWorked', v ? '1' : '0'); })}
|
||||
{fSwitch(t('clu.slotHighlightShort'), clusterSlotHighlight, (v) => { setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); })}
|
||||
{fSwitch(t('clu.lotwOnly'), clusterLotwOnly, (v) => { setClusterLotwOnly(v); writeUiPref('opslog.clusterLotwOnly', v ? '1' : '0'); })}
|
||||
</div>
|
||||
|
||||
{/* Spotter's continent. Not the DX's: this asks whether anyone near you
|
||||
is hearing the band at all, which is a different question. */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{t('clu.spotterCont')}</div>
|
||||
<select
|
||||
className="w-full h-7 rounded border border-border bg-background px-1 text-xs"
|
||||
value={clusterSpotterCont}
|
||||
onChange={(e) => { const v = e.target.value; setClusterSpotterCont(v); writeUiPref('opslog.clusterSpotterCont', v); }}
|
||||
>
|
||||
<option value="">{t('clu.allContinents')}</option>
|
||||
{['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'].map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
{/* The SPOTTER's continent, not the DX's: this asks whether anyone near
|
||||
you is hearing the band at all. Chips rather than a dropdown — seven
|
||||
two-letter codes fit on two lines, they read as a set the way Status
|
||||
and Mode do, and several can be picked at once, which "EU or NA" needs
|
||||
and a dropdown cannot express. */}
|
||||
{fSection(t('clu.spotterCont'),
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'].map((c) => fChip(
|
||||
c, c, 'bg-muted text-foreground border-border',
|
||||
clusterSpotterConts.has(c),
|
||||
() => setClusterSpotterConts((cur) => {
|
||||
const n = new Set(cur);
|
||||
if (n.has(c)) n.delete(c); else n.add(c);
|
||||
return n;
|
||||
}),
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>,
|
||||
clusterSpotterConts.size > 0 ? (
|
||||
<button type="button" onClick={() => setClusterSpotterConts(new Set())}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground underline">{t('clu.clear')}</button>
|
||||
) : undefined,
|
||||
)}
|
||||
|
||||
{/* 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-warning-muted text-warning-muted-foreground border-warning-border' : '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>
|
||||
{fSection(t('clu.bands'),
|
||||
<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);
|
||||
@@ -4864,23 +4860,25 @@ export default function App() {
|
||||
</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-warning-muted text-warning-muted-foreground border-warning-border' : '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>
|
||||
</div>,
|
||||
<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-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
|
||||
title={t('clu.lockBandTitle')}
|
||||
>
|
||||
{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">{t('clu.clear')}</button>
|
||||
)}
|
||||
</div>,
|
||||
)}
|
||||
|
||||
{/* Status filter */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
||||
{fSection(t('clu.status'),
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{([
|
||||
{ k: 'new' as SpotFilterKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
||||
@@ -4899,51 +4897,43 @@ export default function App() {
|
||||
// worked spots; the separate "Hide worked" checkbox drops them — they
|
||||
// are opposite controls, so don't use both at once.
|
||||
{ k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-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>
|
||||
]).map((s) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
|
||||
() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })))}
|
||||
</div>,
|
||||
clusterStatusFilter.size > 0 ? (
|
||||
<button type="button" onClick={() => setClusterStatusFilter(new Set())}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground underline">{t('clu.clear')}</button>
|
||||
) : undefined,
|
||||
)}
|
||||
|
||||
{/* Mode filter */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
|
||||
{fSection(t('clu.mode'),
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{([
|
||||
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
||||
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
||||
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||
]).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>
|
||||
]).map((s) => fChip(s.k, s.label, s.cls, clusterModeFilter.has(s.k),
|
||||
() => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })))}
|
||||
</div>,
|
||||
<button type="button" onClick={() => setClusterLockMode((v) => !v)}
|
||||
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
|
||||
clusterLockMode ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
|
||||
title={t('clu.lockModeTitle')}>
|
||||
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {mode}
|
||||
</button>,
|
||||
)}
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Source</div>
|
||||
{fSection(t('clu.source'),
|
||||
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
|
||||
<SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder={t('clu.allSources')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">All sources</SelectItem>
|
||||
<SelectItem value="_">{t('clu.allSources')}</SelectItem>
|
||||
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Select>,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user