feat: never drop cluster spots + theme-aware spot badges + FR settings

Backend: replace the bounded cluster-event channel (which dropped spots
under RBN bursts once enrichment/UI fell behind) with an unbounded
clusterQueue (slice + sync.Cond). The socket-read goroutine still never
blocks, but a burst now grows the backlog and drains instead of losing
spots. Head compaction keeps a sustained burst from growing the backing
array without bound.

ClusterGrid: drop all hard-coded light-theme hex colours (Time, Country,
Spotter, Comment, POTA, band/mode fills) for semantic CSS-var tokens so
they read correctly on every theme. Replace the heavy full-cell band/mode
fills with small rounded pills (cellChip). Only NEW MODE pills the mode
cell — NEW SLOT (band and mode each worked, just not together) no longer
highlights the mode cell, which wrongly implied the mode was new.

App: stage incoming spots ~50ms and resolve their slot status before
inserting, so a row paints with its badge already on instead of flashing
plain text then flipping to a pill. Self-spot toast still fires per spot.

i18n: translate the General settings that were still English — telemetry
and live-status toggles, the Main view pane pickers and their options.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-18 18:20:06 +02:00
co-authored by Claude Opus 4.8
parent 04eaa91bd8
commit 3ed9f29d9a
5 changed files with 240 additions and 66 deletions
+90 -20
View File
@@ -435,8 +435,8 @@ type App struct {
// to run inline in the read loop, so a single slow step stopped draining the
// TCP socket and the whole feed fell behind the node (visible as the grid
// lagging telnet). The read loop now just enqueues here; one worker does the work.
clusterEventCh chan clusterEvent
clusterDropped int64 // spots/lines dropped when the queue was full (atomic)
// Unbounded FIFO: a burst grows the backlog instead of dropping spots.
clusterEvents *clusterQueue
// wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never
// queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL).
// Loaded once, appended to on each log, rebuilt after bulk changes.
@@ -906,10 +906,11 @@ func (a *App) startup(ctx context.Context) {
// renders the row with all metadata already filled (no flicker of
// empty Country / Cont columns while the batch status fetch runs).
// Cluster events are processed OFF the socket-read goroutine (see clusterEvent /
// clusterEventWorker). Sized large so ordinary traffic and even an SH/DX/100
// burst never fill it; a full queue drops-and-counts rather than block the read
// loop, which was the actual cause of the feed falling behind telnet.
a.clusterEventCh = make(chan clusterEvent, 8192)
// clusterEventWorker). The queue is UNBOUNDED so no spot is ever dropped: an
// SH/DX or RBN burst grows the backlog and drains once enrichment catches up,
// while the read loop still never blocks (that was the cause of the feed falling
// behind telnet).
a.clusterEvents = newClusterQueue()
go a.clusterEventWorker()
a.cluster = cluster.NewManager(
@@ -5902,20 +5903,85 @@ type clusterEvent struct {
line *cluster.Line
}
// enqueueClusterEvent hands an event to the worker WITHOUT blocking. It runs on
// the cluster session's socket-read goroutine: blocking here would stop draining
// the TCP socket, the node's send buffer would fill, and the feed would fall
// behind — the exact bug this indirection fixes. If the queue is full (processing
// can't keep up), drop and count rather than stall the whole feed.
// clusterQueue is an unbounded FIFO between the socket-read goroutines (producers,
// which must never block or the TCP feed stalls) and the single clusterEventWorker
// (consumer). Unlike a bounded channel it NEVER drops an event: a burst just grows
// the backlog, which drains once enrichment catches up.
type clusterQueue struct {
mu sync.Mutex
cond *sync.Cond
buf []clusterEvent
head int // index of the next event to pop (waste reclaimed by compaction)
closed bool
}
func newClusterQueue() *clusterQueue {
q := &clusterQueue{}
q.cond = sync.NewCond(&q.mu)
return q
}
// push appends an event and wakes the worker. Amortised O(1); never blocks, never
// drops.
func (q *clusterQueue) push(ev clusterEvent) {
q.mu.Lock()
if q.closed {
q.mu.Unlock()
return
}
q.buf = append(q.buf, ev)
q.mu.Unlock()
q.cond.Signal()
}
// pop blocks until an event is available, returning ok=false only once the queue
// is closed AND fully drained.
func (q *clusterQueue) pop() (clusterEvent, bool) {
q.mu.Lock()
for q.head == len(q.buf) && !q.closed {
q.cond.Wait()
}
if q.head == len(q.buf) { // closed and drained
q.mu.Unlock()
return clusterEvent{}, false
}
ev := q.buf[q.head]
q.buf[q.head] = clusterEvent{} // release pointers held by the consumed slot
q.head++
switch {
case q.head == len(q.buf):
// Fully drained → reset to reuse the backing array from the front.
q.buf = q.buf[:0]
q.head = 0
case q.head > 1024 && q.head*2 >= len(q.buf):
// Head waste is large → compact so a long sustained burst doesn't grow
// the backing array without bound.
n := copy(q.buf, q.buf[q.head:])
for i := n; i < len(q.buf); i++ {
q.buf[i] = clusterEvent{}
}
q.buf = q.buf[:n]
q.head = 0
}
q.mu.Unlock()
return ev, true
}
// close wakes any waiting consumer so it can exit once the backlog is drained.
func (q *clusterQueue) close() {
q.mu.Lock()
q.closed = true
q.mu.Unlock()
q.cond.Broadcast()
}
// enqueueClusterEvent hands an event to the worker WITHOUT blocking and WITHOUT
// dropping. It runs on the cluster session's socket-read goroutine: blocking here
// would stop draining the TCP socket, the node's send buffer would fill, and the
// feed would fall behind — the exact bug this indirection fixes. The queue is
// unbounded, so a burst grows the backlog rather than losing a spot.
func (a *App) enqueueClusterEvent(ev clusterEvent) {
select {
case a.clusterEventCh <- ev:
default:
n := atomic.AddInt64(&a.clusterDropped, 1)
if n == 1 || n%100 == 0 {
applog.Printf("cluster: processing backlog — dropped %d event(s); the feed is faster than enrichment/UI", n)
}
}
a.clusterEvents.push(ev)
}
// clusterEventWorker drains clusterEventCh and does everything that used to run
@@ -5923,7 +5989,11 @@ func (a *App) enqueueClusterEvent(ev clusterEvent) {
// to the UI, run alert rules (which may query a remote MySQL) and mirror it to the
// Flex panadapter — all serialised on this one goroutine, off the read path.
func (a *App) clusterEventWorker() {
for ev := range a.clusterEventCh {
for {
ev, ok := a.clusterEvents.pop()
if !ok {
return
}
if ev.line != nil {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:line", *ev.line)
+78 -3
View File
@@ -1064,6 +1064,15 @@ export default function App() {
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
// different slots don't share the same colour.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
// still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus);
useEffect(() => { spotStatusRef.current = spotStatus; }, [spotStatus]);
// Incoming spots are staged here for a few ms, their status resolved, then
// committed to `spots` together — so a row paints with its NEW BAND/MODE badge
// already on, instead of flashing plain text then flipping to the pill.
const pendingSpotsRef = useRef<ClusterSpot[]>([]);
const pendingSpotTimer = useRef<number | undefined>(undefined);
// === Modals ===
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
@@ -1740,13 +1749,76 @@ export default function App() {
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
});
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
// Commit the staged spots: resolve the status for any slot we don't know yet
// FIRST, then insert the rows — so they appear with the right badge already
// painted instead of flashing plain text then flipping to a pill.
const flushPendingSpots = async () => {
pendingSpotTimer.current = undefined;
const batch = pendingSpotsRef.current;
pendingSpotsRef.current = [];
if (batch.length === 0) return;
// Resolve unknown statuses before the rows go in.
try {
const known = spotStatusRef.current;
const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
const seen = new Set<string>();
for (const s of batch) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
if (seen.has(k) || known[k]) continue;
seen.add(k);
unknown.push({
call: s.dx_call, band: s.band ?? '',
mode: inferSpotMode(s.comment ?? '', s.freq_hz),
pota_ref: (s as any).pota_ref ?? '',
});
}
if (unknown.length > 0) {
const res = await ClusterSpotStatuses(unknown as any);
setSpotStatus((prev) => {
const next = { ...prev };
for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = {
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
new_county: !!(r as any).new_county,
new_pota: !!(r as any).new_pota,
};
}
return next;
});
}
} catch {}
// Now insert the staged rows. Same de-dupe as before: a station re-spotted
// (RBN skimmers on a CQ, several nodes relaying) shows as ONE live row that
// the freshest spot REPLACES and floats to the top. Historical (SH/DX
// replay) rows are left alone.
setSpots((arr) => {
const next = [sp, ...arr];
const key = (x: ClusterSpot) => `${(x.dx_call ?? '').toUpperCase()}|${(x.band ?? '').toLowerCase()}`;
const hist = (x: ClusterSpot) => !!(x as any).historical;
let next = arr;
for (const sp of batch) {
const k = key(sp);
const filtered = hist(sp) ? next : next.filter((x) => hist(x) || key(x) !== k);
next = [sp, ...filtered];
}
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
});
};
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
// Stage the spot; a short timer resolves its status then commits it.
pendingSpotsRef.current.push(sp);
// 50 ms is enough to coalesce an RBN burst into one status lookup (the
// worked-index is in memory, so resolving is near-instant) while staying
// imperceptible.
if (pendingSpotTimer.current === undefined) {
pendingSpotTimer.current = window.setTimeout(flushPendingSpots, 50);
}
// Self-spot: someone spotted OUR callsign — show it in the shared header
// toast (same place as the other notifications), not a separate banner.
// Fired immediately (not staged) so the notification never lags the spot.
const mine = myCallRef.current;
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
const by = cleanSpotter(sp.spotter ?? '') || '?';
@@ -1754,7 +1826,10 @@ export default function App() {
showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`);
}
});
return () => { unsubState?.(); unsubSpot?.(); };
return () => {
unsubState?.(); unsubSpot?.();
if (pendingSpotTimer.current !== undefined) window.clearTimeout(pendingSpotTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+33 -23
View File
@@ -113,6 +113,23 @@ function tok(name: string, text: string): Badge {
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
}
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
// Status badges) when the slot is notable, instead of flooding the whole cell with
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
// null → plain text, no pill.
function cellChip(value: any, name: string | null): any {
const txt = value === undefined || value === null || value === '' ? '' : String(value);
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
border: `1px solid var(--${name}-border)`, fontWeight: 700, fontSize: 9,
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{txt}</span>
);
}
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
switch (s?.status) {
case 'new': return tok('danger', t('clg2.newDxcc'));
@@ -129,7 +146,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
defaultVisible: true,
sort: 'desc',
cellStyle: { color: '#7a6b50' },
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
@@ -204,7 +221,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
defaultVisible: true,
cellStyle: { color: '#166534' },
cellStyle: { color: 'var(--success)' },
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
},
{
@@ -219,10 +236,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
defaultVisible: true,
cellClass: 'font-mono',
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
: undefined),
// NEW BAND for this entity → small warning pill around the band text.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
},
{
@@ -231,16 +246,11 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
defaultVisible: true,
cellClass: 'font-mono',
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
cellStyle: (p: any) => {
const st = statusFor(p)?.status;
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
// Status badge text tells them apart.
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
return undefined;
},
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}></span>,
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
// for the entity. NEW SLOT means band AND mode were each worked before (just
// not together), so highlighting the mode cell would wrongly imply "CW is new";
// that case is signalled by the Status badge alone.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
tooltipValueGetter: (p: any) => {
const st = statusFor(p)?.status;
if (st === 'new-mode') return t('clg2.tipNewMode');
@@ -252,7 +262,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
cellStyle: { color: '#7a6b50' },
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
@@ -289,7 +299,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
]?.country ?? '',
cellStyle: { color: '#7a6b50' },
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
@@ -298,31 +308,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
]?.continent ?? '',
cellStyle: { color: '#7a6b50', fontSize: 10 },
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
},
{
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
defaultVisible: true,
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
cellStyle: { color: '#7a6b50' },
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
defaultVisible: true,
cellStyle: { color: '#9a8870', fontSize: 10 },
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
},
{
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
cellStyle: { color: '#7a6b50' },
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
defaultVisible: true,
cellStyle: { color: '#7a6b50' },
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
+17 -20
View File
@@ -540,6 +540,7 @@ function AutostartPanelComponent() {
// (a random install ID + version + OS, sent once a day). Real component so it
// can own its state; embedded inside GeneralPanel.
function TelemetryToggle() {
const { t } = useI18n();
const [on, setOn] = useState(true);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
@@ -549,8 +550,8 @@ function TelemetryToggle() {
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={on} disabled={!loaded}
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
Send anonymous usage statistics
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day no callsign or QSO data)</span>
{t('settings.telemetry')}
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
</label>
);
}
@@ -560,6 +561,7 @@ function TelemetryToggle() {
// events — a small web script on your server renders it for the QRZ page. Only
// useful on a MySQL logbook. Self-contained component (owns its async state).
function LiveStatusToggle() {
const { t } = useI18n();
const [on, setOn] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
@@ -569,7 +571,7 @@ function LiveStatusToggle() {
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={on} disabled={!loaded}
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
Publish live operator status <span className="text-xs text-muted-foreground">(multi-op on shared MySQL feeds a QRZ live page)</span>
{t('settings.liveStatus')} <span className="text-xs text-muted-foreground">({t('settings.liveStatusHint')})</span>
</label>
);
}
@@ -578,25 +580,20 @@ function LiveStatusToggle() {
// panes show, independently: the great-circle map, the locator street map, the
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
{ value: 'map1', label: 'Map — great-circle + beam' },
{ value: 'map2', label: 'Map — locator (street)' },
{ value: 'cluster', label: 'Cluster spots' },
{ value: 'worked', label: 'Worked before' },
{ value: 'recent', label: 'Recent QSOs' },
{ value: 'netcontrol', label: 'Net control' },
];
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
const { t } = useI18n();
const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2');
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
const options = [
...MAIN_PANE_OPTIONS,
...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []),
...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []),
].sort((a, b) => a.label.localeCompare(b.label));
...MAIN_PANE_VALUES,
...(flexAvailable ? ['flex'] : []),
...(icomAvailable ? ['icom'] : []),
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
.sort((a, b) => a.label.localeCompare(b.label));
useEffect(() => {
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v);
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
}, []);
@@ -609,11 +606,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
};
return (
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
<h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
<p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
<div className="grid grid-cols-2 gap-3 max-w-xl">
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">Left pane</span>
<span className="text-muted-foreground">{t('settings.leftPane')}</span>
<Select value={left} onValueChange={(v) => pick('left', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -622,7 +619,7 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
</Select>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">Right pane</span>
<span className="text-muted-foreground">{t('settings.rightPane')}</span>
<Select value={right} onValueChange={(v) => pick('right', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
+22
View File
@@ -80,6 +80,17 @@ const en: Dict = {
'offline.synced': '{n} QSO(s) added to the logbook',
'offline.stillDown': 'Database still unreachable — your QSOs are safe',
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
'settings.telemetry': 'Send anonymous usage statistics',
'settings.telemetryHint': 'install ID + version + OS, once a day — no callsign or QSO data',
'settings.liveStatus': 'Publish live operator status',
'settings.liveStatusHint': 'multi-op on shared MySQL — feeds a QRZ live page',
'settings.mainView': 'Main view',
'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).',
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)',
'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before',
'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control',
'settings.pane.flex': 'FlexRadio controls', 'settings.pane.icom': 'Icom console',
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
@@ -359,6 +370,17 @@ const fr: Dict = {
'offline.synced': '{n} QSO ajoutés au journal',
'offline.stillDown': 'Base toujours injoignable — tes QSO sont en sécurité',
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
'settings.telemetry': "Envoyer des statistiques d'usage anonymes",
'settings.telemetryHint': "ID d'installation + version + OS, une fois par jour — aucun indicatif ni donnée QSO",
'settings.liveStatus': 'Publier le statut opérateur en direct',
'settings.liveStatusHint': 'multi-op sur MySQL partagé — alimente une page live QRZ',
'settings.mainView': 'Vue principale',
'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).",
'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)',
'settings.pane.cluster': 'Spots cluster', 'settings.pane.worked': 'Déjà contactés',
'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net',
'settings.pane.flex': 'Contrôles FlexRadio', 'settings.pane.icom': 'Console Icom',
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',