refactor(decodes): real columns, spelled-out flags, bigger type
First pass on the panel from operating feedback. Columns are a grid template shared by the header row and every data row, so the two cannot drift and the eye has a rail to follow. It is capped at 1500 px and centred: free-flowing, a 2500 px window put the country a foot from the callsign it belonged to and left a hole in the middle of every line. "New" gets a COLUMN. It was only a coloured edge before, which says something is special without saying what — and every one of these is a reason to break off what you are doing and call. The entity verdict is a solid badge, the orthogonal ones (park, grid, prefix, county) are outlined in the colours markerColour already gives the cluster list and the band map, so a new park is the same green in all three. Applied inline because those are categorical --chart-* custom properties, which the theme does not expose as Tailwind colour utilities: written as border-chart-7 the badge would simply have had no colour. Band and mode selectors now appear only when the feed actually carries more than one of each. One MSHV is one band and one mode, so for most operators they were furniture; they show up the day a second instance puts a second band on the link, which is the only day they mean anything. Same rule for continent, and a receiver count when more than one instance is feeding. Added a LoTW-only filter, and raised the type throughout (call and message to 14 px, secondary to 12 px, badges to 11 px) with more room per row. The decode payload now carries the sending application's own id. It tells two receivers apart on one multicast group — and it is the address a WSJT-X Reply message would have to go back to, so it is carried now rather than requiring another trip through the parser later.
This commit is contained in:
@@ -16,7 +16,7 @@ import { useMemo, useState } from 'react';
|
||||
import { Radio, Search, X, Signal, ArrowUpRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { activeMarkers, markerColour } from '@/lib/spotMarkers';
|
||||
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
||||
|
||||
export type Decode = {
|
||||
call: string;
|
||||
@@ -32,6 +32,7 @@ export type Decode = {
|
||||
tr_period?: number;
|
||||
off_air?: boolean;
|
||||
source?: string;
|
||||
instance?: string;
|
||||
};
|
||||
|
||||
export type TxMsg = {
|
||||
@@ -69,23 +70,43 @@ interface Props {
|
||||
// arrives here; a wrong guess only mis-groups, it never loses a decode.
|
||||
const DEFAULT_TR = 15;
|
||||
|
||||
// Status → the pill's look. The vocabulary is the cluster's, deliberately: the
|
||||
// same fact must not be amber in one panel and green in the next.
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
'new': 'bg-success text-success-foreground',
|
||||
'new-band': 'bg-warning text-warning-foreground',
|
||||
'new-mode': 'bg-info text-info-foreground',
|
||||
'new-slot': 'bg-caution text-caution-foreground',
|
||||
'new-call': 'bg-muted text-muted-foreground',
|
||||
// ROW is the column template, shared by the header and every row so the two can
|
||||
// never drift. Fixed widths for the short fields, one flexible column for the
|
||||
// message — and a hard cap on the whole grid, because on a 2500 px screen a
|
||||
// free-flowing row puts the country a foot away from the callsign it belongs to
|
||||
// and the eye has to travel the gap on every line.
|
||||
const ROW = 'grid grid-cols-[3px_112px_56px_60px_1fr_260px_170px_28px] gap-x-3 items-center';
|
||||
const ROW_MAX = 'max-w-[1500px]';
|
||||
|
||||
// The "new" badges. Every one of these is a REASON TO CALL, which is why they
|
||||
// get a column of their own rather than a coloured edge: a stripe says something
|
||||
// is special, a badge says what, and the operator is deciding whether to break
|
||||
// off what they are doing.
|
||||
//
|
||||
// Colours match the cluster list and the band map — the same fact must not be
|
||||
// amber in one panel and green in the next.
|
||||
const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground' },
|
||||
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground' },
|
||||
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground' },
|
||||
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground' },
|
||||
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
'new': 'dec.stNew',
|
||||
'new-band': 'dec.stBand',
|
||||
'new-mode': 'dec.stMode',
|
||||
'new-slot': 'dec.stSlot',
|
||||
'new-call': 'dec.stCall',
|
||||
};
|
||||
// The orthogonal ones: a station already worked for its entity can still be a
|
||||
// new grid, a new prefix or a park never logged.
|
||||
//
|
||||
// The colour comes from markerColour, the table the cluster list and the band
|
||||
// map read — so a new park is the same green in all three. Applied inline
|
||||
// because those are categorical --chart-* variables, which the theme exposes as
|
||||
// CSS custom properties and not as Tailwind colour utilities; every other place
|
||||
// that paints with them does the same.
|
||||
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
|
||||
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
|
||||
{ key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' },
|
||||
{ key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' },
|
||||
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
|
||||
];
|
||||
|
||||
// periodStart floors a decode's own timestamp to its slot. Its OWN timestamp,
|
||||
// not arrival: a period's decodes reach us in one burst a second or two after
|
||||
@@ -101,19 +122,20 @@ function periodStart(at: string, tr: number): number {
|
||||
const hhmmss = (epochSec: number) =>
|
||||
new Date(epochSec * 1000).toISOString().slice(11, 19);
|
||||
|
||||
// snrTone colours the report by readability rather than by a gradient: an
|
||||
// snrTone colours the report by readability rather than as a gradient: an
|
||||
// operator scanning a period wants "workable" to jump out, and -24 dB is not
|
||||
// three shades worse than -6, it is a different decision.
|
||||
function snrTone(snr: number): string {
|
||||
if (snr >= -5) return 'text-success';
|
||||
if (snr >= -15) return 'text-foreground';
|
||||
return 'text-muted-foreground';
|
||||
return 'text-muted-foreground/70';
|
||||
}
|
||||
|
||||
export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [cqOnly, setCqOnly] = useState(false);
|
||||
const [newOnly, setNewOnly] = useState(false);
|
||||
const [lotwOnly, setLotwOnly] = useState(false);
|
||||
const [bandSel, setBandSel] = useState('');
|
||||
const [modeSel, setModeSel] = useState('');
|
||||
const [contSel, setContSel] = useState('');
|
||||
@@ -123,18 +145,21 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
||||
const statusOf = (d: Decode): StatusEntry | undefined =>
|
||||
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
|
||||
|
||||
// The band / mode / continent choices are built from what is actually on the
|
||||
// feed, not from a fixed list: a selector offering 160 m to an operator whose
|
||||
// receivers are all on 6 m is noise.
|
||||
const { bands, modes, conts } = useMemo(() => {
|
||||
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>();
|
||||
// The choices are built from what is actually on the feed, and a selector with
|
||||
// nothing to choose is HIDDEN. One MSHV is one band and one mode, so those two
|
||||
// dropdowns were pure furniture for most operators; they appear the day a
|
||||
// second instance puts a second band on the link, which is the only day they
|
||||
// mean anything.
|
||||
const { bands, modes, conts, instances } = useMemo(() => {
|
||||
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>(), i = new Set<string>();
|
||||
for (const d of decodes) {
|
||||
if (d.band) b.add(d.band);
|
||||
if (d.mode) m.add(d.mode);
|
||||
if (d.instance) i.add(d.instance);
|
||||
const ct = statusOf(d)?.continent;
|
||||
if (ct) c.add(ct);
|
||||
}
|
||||
return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort() };
|
||||
return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort(), instances: [...i].sort() };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [decodes, spotStatus]);
|
||||
|
||||
@@ -150,13 +175,14 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
||||
if (modeSel && d.mode !== modeSel) return false;
|
||||
if (floor != null && Number.isFinite(floor) && d.snr < floor) return false;
|
||||
const e = statusOf(d);
|
||||
if (lotwOnly && !e?.lotw) return false;
|
||||
if (newOnly && !isNewSomething(e)) return false;
|
||||
if (contSel && e?.continent !== contSel) return false;
|
||||
if (q && !(d.call.includes(q) || (d.grid ?? '').toUpperCase().includes(q) || (d.msg ?? '').toUpperCase().includes(q))) return false;
|
||||
return true;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [decodes, spotStatus, cqOnly, newOnly, bandSel, modeSel, contSel, minSnr, search]);
|
||||
}, [decodes, spotStatus, cqOnly, newOnly, lotwOnly, bandSel, modeSel, contSel, minSnr, search]);
|
||||
|
||||
// Group into periods, newest first, and drop the operator's transmissions into
|
||||
// the slot they went out in.
|
||||
@@ -188,78 +214,113 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
||||
}, [filtered, txMsgs]);
|
||||
|
||||
const resetFilters = () => {
|
||||
setCqOnly(false); setNewOnly(false); setBandSel(''); setModeSel('');
|
||||
setContSel(''); setMinSnr(''); setSearch('');
|
||||
setCqOnly(false); setNewOnly(false); setLotwOnly(false); setBandSel('');
|
||||
setModeSel(''); setContSel(''); setMinSnr(''); setSearch('');
|
||||
};
|
||||
const anyFilter = cqOnly || newOnly || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim();
|
||||
const anyFilter = cqOnly || newOnly || lotwOnly || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim();
|
||||
|
||||
const sel = 'h-7 rounded-md border border-border bg-background px-2 text-xs';
|
||||
const chip = (on: boolean) => cn(
|
||||
'h-7 px-2.5 rounded-full border text-xs font-medium transition-colors',
|
||||
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted',
|
||||
const sel = 'h-8 rounded-lg border border-border bg-background px-2 text-sm';
|
||||
const chip = (on: boolean, tone = 'primary') => cn(
|
||||
'h-8 px-3 rounded-full border text-sm font-medium transition-colors',
|
||||
on
|
||||
? tone === 'success'
|
||||
? 'border-success bg-success text-success-foreground'
|
||||
: 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* ── Filter bar ─────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-3 py-2 border-b border-border bg-muted/30 shrink-0">
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-border bg-muted/30 shrink-0">
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mr-1">
|
||||
<span className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mr-1">
|
||||
{t('dec.title')}
|
||||
</span>
|
||||
|
||||
<button type="button" className={chip(cqOnly)} onClick={() => setCqOnly((v) => !v)}>
|
||||
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly((v) => !v)}>
|
||||
{t('dec.cqOnly')}
|
||||
</button>
|
||||
<button type="button" className={chip(newOnly)} onClick={() => setNewOnly((v) => !v)}>
|
||||
{t('dec.newOnly')}
|
||||
</button>
|
||||
<button type="button" className={chip(lotwOnly)} onClick={() => setLotwOnly((v) => !v)}>
|
||||
{t('dec.lotwOnly')}
|
||||
</button>
|
||||
|
||||
<select className={sel} value={bandSel} onChange={(e) => setBandSel(e.target.value)}>
|
||||
<option value="">{t('dec.allBands')}</option>
|
||||
{bands.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
<select className={sel} value={modeSel} onChange={(e) => setModeSel(e.target.value)}>
|
||||
<option value="">{t('dec.allModes')}</option>
|
||||
{modes.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<select className={sel} value={contSel} onChange={(e) => setContSel(e.target.value)}>
|
||||
<option value="">{t('dec.allConts')}</option>
|
||||
{conts.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
{/* Only when there is a choice to make — see the memo above. */}
|
||||
{bands.length > 1 && (
|
||||
<select className={sel} value={bandSel} onChange={(e) => setBandSel(e.target.value)}>
|
||||
<option value="">{t('dec.allBands')}</option>
|
||||
{bands.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{modes.length > 1 && (
|
||||
<select className={sel} value={modeSel} onChange={(e) => setModeSel(e.target.value)}>
|
||||
<option value="">{t('dec.allModes')}</option>
|
||||
{modes.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{conts.length > 1 && (
|
||||
<select className={sel} value={contSel} onChange={(e) => setContSel(e.target.value)}>
|
||||
<option value="">{t('dec.allConts')}</option>
|
||||
{conts.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Signal className="size-3.5" />
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Signal className="size-4" />
|
||||
<input
|
||||
type="number" placeholder="dB" value={minSnr}
|
||||
onChange={(e) => setMinSnr(e.target.value)}
|
||||
className="h-7 w-16 rounded-md border border-border bg-background px-1.5 text-xs tabular-nums"
|
||||
className="h-8 w-20 rounded-lg border border-border bg-background px-2 text-sm tabular-nums"
|
||||
title={t('dec.minSnrTitle')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('dec.searchPh')}
|
||||
className="h-7 w-44 rounded-md border border-border bg-background pl-7 pr-2 text-xs"
|
||||
className="h-8 w-56 rounded-lg border border-border bg-background pl-8 pr-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{anyFilter && (
|
||||
<button type="button" onClick={resetFilters}
|
||||
className="h-7 px-2 rounded-md text-xs text-muted-foreground hover:bg-muted inline-flex items-center gap-1">
|
||||
<X className="size-3" /> {t('dec.clearFilters')}
|
||||
className="h-8 px-2.5 rounded-lg text-sm text-muted-foreground hover:bg-muted hover:text-foreground inline-flex items-center gap-1">
|
||||
<X className="size-3.5" /> {t('dec.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="flex-1" />
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{/* Which receivers are feeding this. Only with more than one — with a
|
||||
single MSHV it is a label stating the obvious. */}
|
||||
{instances.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('dec.instances', { n: instances.length })}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{t('dec.count', { shown: filtered.length, total: decodes.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Column header ──────────────────────────────────────────── */}
|
||||
<div className="px-4 shrink-0 border-b border-border bg-background">
|
||||
<div className={cn(ROW, ROW_MAX, 'mx-auto py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground')}>
|
||||
<span />
|
||||
<span>{t('dec.colCall')}</span>
|
||||
<span className="text-right">{t('dec.colSnr')}</span>
|
||||
<span>{t('dec.colGrid')}</span>
|
||||
<span>{t('dec.colMsg')}</span>
|
||||
<span>{t('dec.colFlags')}</span>
|
||||
<span>{t('dec.colCountry')}</span>
|
||||
<span className="text-center" title="LoTW">L</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Periods ────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{groups.length === 0 && (
|
||||
@@ -271,105 +332,108 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
||||
)}
|
||||
|
||||
{groups.map((g, gi) => (
|
||||
<section key={g.start} className="border-b border-border/60">
|
||||
{/* Period header — sticky so the slot you are reading is always named. */}
|
||||
<header className={cn(
|
||||
'sticky top-0 z-10 flex items-center gap-2 px-3 py-1 backdrop-blur',
|
||||
'bg-muted/80 border-b border-border/60',
|
||||
)}>
|
||||
<span className={cn('font-mono text-xs font-bold tabular-nums',
|
||||
gi === 0 ? 'text-primary' : 'text-foreground')}>
|
||||
{hhmmss(g.start)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t('dec.periodCount', { n: g.decodes.length })}
|
||||
</span>
|
||||
{gi === 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-success">
|
||||
<span className="size-1.5 rounded-full bg-success animate-pulse" />
|
||||
{t('dec.live')}
|
||||
<section key={g.start}>
|
||||
{/* Period header — sticky, so the slot being read is always named. */}
|
||||
<header className="sticky top-0 z-10 px-4 bg-muted/95 backdrop-blur border-y border-border/60">
|
||||
<div className={cn(ROW_MAX, 'mx-auto flex items-center gap-2.5 py-1.5')}>
|
||||
<span className={cn('font-mono text-sm font-bold tabular-nums',
|
||||
gi === 0 ? 'text-primary' : 'text-foreground')}>
|
||||
{hhmmss(g.start)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('dec.periodCount', { n: g.decodes.length })}
|
||||
</span>
|
||||
{gi === 0 && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-success">
|
||||
<span className="size-1.5 rounded-full bg-success animate-pulse" />
|
||||
{t('dec.live')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* The operator's own transmission, at the top of its slot: it is what
|
||||
the stations below were answering (or ignoring). */}
|
||||
{g.tx.map((m, i) => (
|
||||
<div key={`tx-${i}`}
|
||||
className="flex items-center gap-2 px-3 py-1 bg-primary/10 border-l-2 border-primary">
|
||||
<ArrowUpRight className="size-3.5 text-primary shrink-0" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-primary shrink-0">
|
||||
{t('dec.tx')}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-foreground truncate">{m.msg}</span>
|
||||
{m.band && <span className="ml-auto text-[10px] text-muted-foreground shrink-0">{m.band}</span>}
|
||||
<div key={`tx-${i}`} className="px-4 bg-primary/10">
|
||||
<div className={cn(ROW_MAX, 'mx-auto flex items-center gap-2.5 py-1.5 border-l-2 border-primary pl-2')}>
|
||||
<ArrowUpRight className="size-4 text-primary shrink-0" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-primary shrink-0">
|
||||
{t('dec.tx')}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-foreground truncate">{m.msg}</span>
|
||||
{m.band && <span className="ml-auto text-xs text-muted-foreground shrink-0">{m.band}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{g.decodes.map((d, i) => {
|
||||
const e = statusOf(d);
|
||||
const st = e?.status && e.status !== 'worked' ? e.status : '';
|
||||
const markers = activeMarkers(e as any);
|
||||
const entity = st ? ENTITY_BADGE[st] : undefined;
|
||||
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]);
|
||||
const mine = myCall && d.call === myCall.toUpperCase();
|
||||
const hot = !!entity || extras.length > 0;
|
||||
return (
|
||||
<button
|
||||
key={`${d.call}-${d.freq_hz}-${i}`}
|
||||
type="button"
|
||||
onClick={() => onCall(d)}
|
||||
title={t('dec.callTitle', { call: d.call })}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-1 text-left transition-colors',
|
||||
'hover:bg-muted/60 active:scale-[0.998]',
|
||||
mine && 'bg-info/10',
|
||||
)}
|
||||
>
|
||||
{/* Marker rail: one segment per orthogonal flag, same colours as
|
||||
the cluster list and the band map. */}
|
||||
<span className="flex flex-col w-1 h-5 rounded-full overflow-hidden shrink-0">
|
||||
{markers.length === 0
|
||||
? <span className="flex-1 bg-transparent" />
|
||||
: markers.map((m) => (
|
||||
<span key={m.key} className="flex-1" style={{ background: markerColour(m.key) }} />
|
||||
<div key={`${d.call}-${d.freq_hz}-${i}`}
|
||||
className={cn('px-4 transition-colors', mine ? 'bg-info/10' : 'hover:bg-muted/50')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCall(d)}
|
||||
title={t('dec.callTitle', { call: d.call })}
|
||||
className={cn(ROW, ROW_MAX, 'mx-auto w-full py-1.5 text-left')}
|
||||
>
|
||||
{/* A thin accent, kept only as a hint that the row carries a
|
||||
reason — the reasons themselves are spelled out in the
|
||||
flags column, where they can be read. */}
|
||||
<span className={cn('h-5 rounded-full', hot ? 'bg-success' : 'bg-transparent')} />
|
||||
|
||||
<span className={cn('font-mono text-sm font-bold truncate',
|
||||
e?.worked_call ? 'text-muted-foreground' : 'text-foreground')}>
|
||||
{d.call}
|
||||
</span>
|
||||
|
||||
<span className={cn('font-mono text-sm text-right tabular-nums', snrTone(d.snr))}>
|
||||
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
||||
</span>
|
||||
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{d.grid ?? ''}
|
||||
</span>
|
||||
|
||||
<span className="font-mono text-sm truncate min-w-0">
|
||||
{d.cq && <span className="font-bold text-success mr-1.5">CQ</span>}
|
||||
<span className="text-muted-foreground">{d.msg}</span>
|
||||
</span>
|
||||
|
||||
{/* Flags — the column this panel exists for. */}
|
||||
<span className="flex items-center gap-1 overflow-hidden">
|
||||
{entity && (
|
||||
<span className={cn('text-[11px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded shrink-0', entity.cls)}>
|
||||
{t(entity.label)}
|
||||
</span>
|
||||
)}
|
||||
{extras.map((b) => (
|
||||
<span key={b.key as string}
|
||||
className="text-[11px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border bg-transparent shrink-0"
|
||||
style={{ borderColor: markerColour(b.marker), color: markerColour(b.marker) }}>
|
||||
{t(b.label)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
|
||||
<span className={cn('font-mono text-xs font-bold w-[92px] shrink-0 truncate',
|
||||
e?.worked_call ? 'text-muted-foreground' : 'text-foreground')}>
|
||||
{d.call}
|
||||
</span>
|
||||
|
||||
<span className={cn('font-mono text-xs w-11 text-right tabular-nums shrink-0', snrTone(d.snr))}>
|
||||
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
||||
</span>
|
||||
|
||||
<span className="font-mono text-[11px] text-muted-foreground w-14 shrink-0">
|
||||
{d.grid ?? ''}
|
||||
</span>
|
||||
|
||||
{d.cq && (
|
||||
<span className="text-[10px] font-bold text-success shrink-0">CQ</span>
|
||||
)}
|
||||
|
||||
<span className="font-mono text-[11px] text-muted-foreground truncate flex-1 min-w-0">
|
||||
{d.msg}
|
||||
</span>
|
||||
|
||||
{e?.country && (
|
||||
<span className="text-[11px] text-muted-foreground truncate max-w-[130px] shrink-0 hidden xl:block">
|
||||
{e.country}
|
||||
</span>
|
||||
)}
|
||||
{e?.lotw && (
|
||||
<span className="text-[9px] font-bold px-1 rounded bg-info-muted text-info-muted-foreground shrink-0"
|
||||
title="LoTW">L</span>
|
||||
)}
|
||||
{st && (
|
||||
<span className={cn('text-[9px] font-bold uppercase tracking-wide px-1.5 py-px rounded shrink-0',
|
||||
STATUS_STYLE[st] ?? 'bg-muted text-muted-foreground')}>
|
||||
{t(STATUS_LABEL[st] ?? 'dec.stNew')}
|
||||
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{e?.country ?? ''}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className="text-center">
|
||||
{e?.lotw && (
|
||||
<span className="text-[11px] font-bold px-1.5 py-0.5 rounded bg-info-muted text-info-muted-foreground"
|
||||
title="LoTW">L</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user