feat(dxped): the announced DX, judged against your own log
A DXpeditions tab holding the two feeds the DX world announces itself on: NG3K's ADXO (structured — dates, entity, calls, bands, modes, QSL route) and DX-World's headlines. What a logger can say that a news reader cannot is whether the operation is worth chasing, so every announcement is put through the SAME verdict the cluster paints on a spot — one badge, strongest wins, dimmed when the need is only a missing QSL — with an 'only what I need' filter. One click watches every callsign of an operation; the news headlines are mined for callsigns so they can be watched the same way. Parsers ported from DXHunter's and pinned with table tests against real feed text: the 'as PJ2/W2APF' mining, the DXCC-prefix-first normalisation without which no spot ever matches, both ADXO date forms, and the '160-6m' span whose unit is written once (DXHunter read that as 6m alone and lost the low end). Watchlist membership now announces itself app-wide, on the same card as a new version: the bindings emit watchlist:changed, so a call added from the cluster or this tab is confirmed where the operator is actually looking — the panel's own inline message never was.
This commit is contained in:
+71
-1
@@ -1,7 +1,7 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
||||
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -76,6 +76,7 @@ import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { FTMapPanel } from '@/components/FTMapPanel';
|
||||
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
|
||||
import { FirstRunModal } from '@/components/FirstRunModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { BandMap } from '@/components/BandMap';
|
||||
@@ -1315,6 +1316,17 @@ export default function App() {
|
||||
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
|
||||
}
|
||||
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
|
||||
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
|
||||
function openDxpedTab() {
|
||||
setDxpedTabOpen(true);
|
||||
writeUiPref('opslog.dxpedTab', '1');
|
||||
setActiveTab('dxped');
|
||||
}
|
||||
function closeDxpedTab() {
|
||||
setDxpedTabOpen(false);
|
||||
writeUiPref('opslog.dxpedTab', '0');
|
||||
setActiveTab((t) => (t === 'dxped' ? 'recent' : t));
|
||||
}
|
||||
function openFtmapTab() {
|
||||
setFtmapTabOpen(true);
|
||||
writeUiPref('opslog.ftmapTab', '1');
|
||||
@@ -2346,6 +2358,18 @@ export default function App() {
|
||||
}, [showSettings]);
|
||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
||||
// Watchlist membership changes announce themselves here rather than inside
|
||||
// the watchlist panel: a call can be added from the cluster or the
|
||||
// DXpeditions tab, and the old inline message lived on a page nobody was
|
||||
// looking at.
|
||||
const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
|
||||
const wlNoticeTimer = useRef<number | undefined>(undefined);
|
||||
useEffect(() => EventsOn('watchlist:changed', (e: any) => {
|
||||
if (!e?.call) return;
|
||||
setWlNotice({ call: String(e.call), added: !!e.added });
|
||||
if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
|
||||
wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
|
||||
}), []);
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
// Fresh update check on demand (opening About), so it never shows a stale
|
||||
// "you're up to date". Clears updateInfo when the latest check finds nothing.
|
||||
@@ -5126,6 +5150,7 @@ export default function App() {
|
||||
]},
|
||||
{ name: 'tools', label: t('menu.tools'), items: [
|
||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
|
||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||
@@ -5180,6 +5205,7 @@ export default function App() {
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||
case 'tools.dxped': openDxpedTab(); break;
|
||||
case 'tools.decodes': openDecodesTab(); break;
|
||||
case 'tools.ftmap': openFtmapTab(); break;
|
||||
case 'tools.grids': openGridsTab(); break;
|
||||
@@ -7076,6 +7102,26 @@ export default function App() {
|
||||
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
|
||||
)}
|
||||
|
||||
{wlNotice && (
|
||||
<div className={cn('fixed right-4 z-[150] w-72 rounded-lg border bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in',
|
||||
wlNotice.added ? 'border-warning/40' : 'border-border',
|
||||
// Stacked above the update card when both are up.
|
||||
updateInfo ? 'bottom-44' : 'bottom-4')}>
|
||||
<div className="flex items-start gap-2">
|
||||
<Star className={cn('size-4 mt-0.5 shrink-0', wlNotice.added ? 'fill-current text-warning' : 'text-muted-foreground')} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">
|
||||
{t(wlNotice.added ? 'wlnote.added' : 'wlnote.removed', { call: wlNotice.call })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('wlnote.hint')}</p>
|
||||
</div>
|
||||
<button onClick={() => setWlNotice(null)}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground" aria-label="Close">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{updateInfo && (
|
||||
<div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -7816,6 +7862,21 @@ export default function App() {
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{dxpedTabOpen && (
|
||||
<TabsTrigger value="dxped" className="gap-1.5">
|
||||
{t('dxp.tab')}
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Close DXpeditions"
|
||||
title="Close"
|
||||
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => { e.stopPropagation(); closeDxpedTab(); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{ftmapTabOpen && (
|
||||
<TabsTrigger value="ftmap" className="gap-1.5">
|
||||
{t('ftmap.tab')}
|
||||
@@ -8429,6 +8490,15 @@ export default function App() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{dxpedTabOpen && (
|
||||
<TabsContent value="dxped" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||
{activeTab === 'dxped' && (
|
||||
<div className="h-full w-full min-h-0 p-1">
|
||||
<DXpeditionsPanel />
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
{ftmapTabOpen && (
|
||||
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||
{activeTab === 'ftmap' && (
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { RefreshCw, Star, ExternalLink } from 'lucide-react';
|
||||
import { GetDXpeditions, GetDXWorldNews, RefreshDXpeditions, WatchlistEntries, WatchlistAdd } from '../../wailsjs/go/main/App';
|
||||
import { BrowserOpenURL, EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// DXpeditions — the two feeds the DX world announces itself on, side by side.
|
||||
//
|
||||
// Left: NG3K's ADXO, the structured announcements, each judged against THIS log
|
||||
// so the list reads as "what I still need" rather than "what is on". Right:
|
||||
// DX-World's headlines, whose callsigns are mined out of the title so they can
|
||||
// be watched with the same one click.
|
||||
|
||||
type DXped = {
|
||||
dxcc: string; callsign: string; calls?: string[];
|
||||
start_date: string; end_date: string;
|
||||
bands?: string[]; modes?: string[];
|
||||
qsl: string; operators: string; source: string; link: string;
|
||||
status: string; // active | upcoming
|
||||
status_chase: string; // new | new-band-mode | new-band | new-mode | new-slot | worked | ''
|
||||
unconfirmed?: boolean;
|
||||
};
|
||||
|
||||
type News = {
|
||||
title: string; link: string; pub_date: string; excerpt: string;
|
||||
creator: string; image_url: string; tag: string; calls?: string[];
|
||||
};
|
||||
|
||||
// One badge per expedition, the strongest verdict winning — the same palette
|
||||
// and the same words the cluster uses, so the two views teach one vocabulary.
|
||||
const CHASE_BADGE: Record<string, { label: string; colour: string }> = {
|
||||
'new': { label: 'clg2.newDxcc', colour: 'var(--danger)' },
|
||||
'new-band-mode': { label: 'clg2.newBandMode', colour: 'var(--danger)' },
|
||||
'new-band': { label: 'clg2.newBand', colour: 'var(--warning)' },
|
||||
'new-mode': { label: 'clg2.newMode', colour: 'var(--caution)' },
|
||||
'new-slot': { label: 'clg2.newSlot', colour: '#5AC8FA' },
|
||||
'worked': { label: 'wl.worked', colour: 'var(--info)' },
|
||||
};
|
||||
|
||||
export function DXpeditionsPanel() {
|
||||
const { t } = useI18n();
|
||||
const [peds, setPeds] = useState<DXped[]>([]);
|
||||
const [news, setNews] = useState<News[]>([]);
|
||||
const [watched, setWatched] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [neededOnly, setNeededOnly] = useState(() => localStorage.getItem('opslog.dxpedNeeded') === '1');
|
||||
|
||||
const loadWatchlist = useCallback(async () => {
|
||||
try {
|
||||
const e: any[] = await WatchlistEntries();
|
||||
setWatched(new Set((e ?? []).map((x) => String(x.callsign ?? '').toUpperCase())));
|
||||
} catch { /* the list simply shows every call as unwatched */ }
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
const [p, n] = await Promise.allSettled([GetDXpeditions(), GetDXWorldNews()]);
|
||||
if (p.status === 'fulfilled') setPeds((p.value as any) ?? []);
|
||||
else setErr(String((p.reason as any)?.message ?? p.reason));
|
||||
if (n.status === 'fulfilled') setNews((n.value as any) ?? []);
|
||||
setBusy(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); void loadWatchlist(); }, [load, loadWatchlist]);
|
||||
useEffect(() => EventsOn('watchlist:changed', () => { void loadWatchlist(); }), [loadWatchlist]);
|
||||
|
||||
const refresh = async () => { await RefreshDXpeditions(); await load(); };
|
||||
|
||||
const addAll = async (calls: string[]) => {
|
||||
for (const c of calls) {
|
||||
if (!watched.has(c.toUpperCase())) {
|
||||
try { await WatchlistAdd(c, false); } catch { /* reported by the notice */ }
|
||||
}
|
||||
}
|
||||
await loadWatchlist();
|
||||
};
|
||||
|
||||
const shown = useMemo(
|
||||
() => (neededOnly ? peds.filter((p) => p.status_chase && p.status_chase !== 'worked') : peds),
|
||||
[peds, neededOnly]);
|
||||
|
||||
// A row's calls: the mined ones, else whatever the announcement called it.
|
||||
const callsOf = (p: DXped) => (p.calls?.length ? p.calls : p.callsign ? [p.callsign] : []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 gap-3">
|
||||
{/* ── Announcements (ADXO) ── */}
|
||||
<section className="flex flex-col min-h-0 flex-[3] rounded-lg border border-border bg-card overflow-hidden">
|
||||
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||
<span className="text-sm font-semibold">{t('dxp.announced')}</span>
|
||||
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'NG3K ADXO' })}</span>
|
||||
<label className="ml-auto flex items-center gap-1.5 text-[11px] cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
<input type="checkbox" checked={neededOnly}
|
||||
onChange={(e) => { setNeededOnly(e.target.checked); localStorage.setItem('opslog.dxpedNeeded', e.target.checked ? '1' : '0'); }} />
|
||||
{t('dxp.neededOnly')}
|
||||
</label>
|
||||
<Button variant="outline" size="sm" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('dxp.refresh')}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{err && <div className="px-3 py-2 text-xs text-danger shrink-0">{err}</div>}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||
{shown.length === 0 && !busy && (
|
||||
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.none')}</p>
|
||||
)}
|
||||
{shown.map((p, i) => {
|
||||
const calls = callsOf(p);
|
||||
const badge = CHASE_BADGE[p.status_chase];
|
||||
const allWatched = calls.length > 0 && calls.every((c) => watched.has(c.toUpperCase()));
|
||||
return (
|
||||
<article key={`${p.callsign}-${p.start_date}-${i}`}
|
||||
className={cn('rounded-md border p-2 text-xs',
|
||||
p.status === 'active' ? 'border-success/40 bg-success/5' : 'border-border bg-muted/20')}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono font-bold text-sm text-info">{p.callsign || '—'}</span>
|
||||
<span className="font-medium">{p.dxcc}</span>
|
||||
{p.status === 'active' && (
|
||||
<span className="px-1 py-px rounded text-[10px] font-bold uppercase bg-success-muted text-success-muted-foreground">
|
||||
{t('dxp.onAir')}
|
||||
</span>
|
||||
)}
|
||||
{badge && (
|
||||
<span className="px-1 py-px rounded text-[10px] font-bold uppercase tracking-wide border"
|
||||
title={p.unconfirmed ? t('dec.unconfTip') : undefined}
|
||||
style={p.unconfirmed
|
||||
? { color: badge.colour, borderColor: badge.colour, borderStyle: 'dashed', opacity: 0.6 }
|
||||
: { color: badge.colour, borderColor: badge.colour }}>
|
||||
{t(badge.label)}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{p.start_date} → {p.end_date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground">
|
||||
{!!p.bands?.length && <span>{p.bands.join(' · ')}</span>}
|
||||
{!!p.modes?.length && <span className="text-foreground/70">{p.modes.join(' ')}</span>}
|
||||
{p.qsl && <span>QSL: {p.qsl}</span>}
|
||||
{p.source && <span>· {p.source}</span>}
|
||||
</div>
|
||||
{p.operators && <p className="mt-0.5 text-[11px] text-muted-foreground truncate">{p.operators}</p>}
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<Button variant={allWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
|
||||
disabled={calls.length === 0 || allWatched}
|
||||
onClick={() => addAll(calls)}
|
||||
title={t('dxp.watchTip')}>
|
||||
<Star className={cn('size-3', allWatched && 'fill-current text-warning')} />
|
||||
{allWatched ? t('dxp.watched') : t('dxp.watch')}
|
||||
</Button>
|
||||
{p.link && (
|
||||
<button type="button" onClick={() => BrowserOpenURL(p.link)}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
|
||||
<ExternalLink className="size-3" /> {t('dxp.open')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── News (DX-World) ── */}
|
||||
<section className="flex flex-col min-h-0 flex-[2] rounded-lg border border-border bg-card overflow-hidden">
|
||||
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||
<span className="text-sm font-semibold">{t('dxp.news')}</span>
|
||||
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'DX-World' })}</span>
|
||||
</header>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||
{news.length === 0 && !busy && (
|
||||
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.noNews')}</p>
|
||||
)}
|
||||
{news.map((n, i) => (
|
||||
<article key={`${n.link}-${i}`} className="rounded-md border border-border bg-muted/20 p-2">
|
||||
<div className="flex items-start gap-2">
|
||||
{n.image_url && (
|
||||
<img src={n.image_url} alt="" className="size-12 rounded object-cover shrink-0"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{n.tag && (
|
||||
<span className="px-1 py-px rounded text-[9px] font-bold uppercase bg-primary/15 text-primary">{n.tag}</span>
|
||||
)}
|
||||
<button type="button" onClick={() => n.link && BrowserOpenURL(n.link)}
|
||||
className="text-xs font-medium text-left hover:underline">{n.title}</button>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground line-clamp-3">{n.excerpt}</p>
|
||||
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
|
||||
{n.pub_date && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(n.pub_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{(n.calls ?? []).map((c) => (
|
||||
<button key={c} type="button"
|
||||
disabled={watched.has(c.toUpperCase())}
|
||||
onClick={() => addAll([c])}
|
||||
title={watched.has(c.toUpperCase()) ? t('dxp.watched') : t('dxp.watchTip')}
|
||||
className={cn('px-1 py-px rounded font-mono text-[10px] border',
|
||||
watched.has(c.toUpperCase())
|
||||
? 'border-warning/50 text-warning'
|
||||
: 'border-border text-info hover:bg-muted')}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -181,7 +181,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
try {
|
||||
await WatchlistAdd(c, addContest);
|
||||
setAddCall('');
|
||||
flash(t(addContest ? 'wl.addedContest' : 'wl.added', { call: c }), false);
|
||||
// The confirmation is the app-level notice (App.tsx, on watchlist:changed)
|
||||
// — saying it twice, once per place a call can be added from, was noise.
|
||||
await refresh();
|
||||
} catch (e: any) { flash(String(e?.message ?? e), true); }
|
||||
};
|
||||
@@ -190,7 +191,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
// entry is cheap to undo (type it again), so it does not earn a confirmation
|
||||
// the other two buttons do not have.
|
||||
const remove = async (call: string) => {
|
||||
try { await WatchlistRemove(call); flash(t('wl.removed', { call }), false); await refresh(); }
|
||||
try { await WatchlistRemove(call); await refresh(); }
|
||||
catch (e: any) { flash(String(e?.message ?? e), true); }
|
||||
};
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const en: Dict = {
|
||||
'wl.totalSpots': '{n} spots', 'wl.toggleContest': 'Contest entry: judged per UTC day',
|
||||
'wl.notifyOn': 'Alert me when this station is spotted', 'wl.notifyOff': 'Stop alerting for this station',
|
||||
'wl.remove': 'Remove', 'wl.removed': '{call} removed from the watchlist.', 'wl.noSpots': 'No live spots',
|
||||
'wl.newDxcc': 'NEW DXCC', 'wl.worked': 'Worked', 'wl.needed': 'Needed!', 'wl.todayOk': 'Today ✓', 'wl.workToday': 'Work today!',
|
||||
'wl.newDxcc': 'NEW DXCC', 'dxp.tab': 'DXpeditions', 'dxp.announced': 'Announced operations', 'dxp.news': 'DX news', 'dxp.source': 'from {name}', 'dxp.neededOnly': 'Only what I need', 'dxp.refresh': 'Refresh', 'dxp.none': 'No announced operation — or the feed could not be read.', 'dxp.noNews': 'No news.', 'dxp.onAir': 'ON AIR', 'dxp.watch': 'Watch', 'dxp.watched': 'Watched', 'dxp.watchTip': 'Add to the watchlist', 'dxp.open': 'Open', 'wlnote.added': '{call} added to the watchlist', 'wlnote.removed': '{call} removed from the watchlist', 'wlnote.hint': 'Its spots are highlighted in the cluster.', 'wl.worked': 'Worked', 'wl.needed': 'Needed!', 'wl.todayOk': 'Today ✓', 'wl.workToday': 'Work today!',
|
||||
'wl.spotTip': 'Click: fill the callsign · double-click: tune and work',
|
||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
||||
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
|
||||
@@ -574,7 +574,7 @@ const fr: Dict = {
|
||||
'wl.totalSpots': '{n} spots', 'wl.toggleContest': 'Entrée contest : jugée par jour UTC',
|
||||
'wl.notifyOn': "M'alerter quand cette station est spottée", 'wl.notifyOff': "Ne plus alerter pour cette station",
|
||||
'wl.remove': 'Supprimer', 'wl.removed': '{call} retiré de la watchlist.', 'wl.noSpots': 'Aucun spot en cours',
|
||||
'wl.newDxcc': 'NOUV DXCC', 'wl.worked': 'Contacté', 'wl.needed': 'Manquant !', 'wl.todayOk': "Auj. ✓", 'wl.workToday': "À faire auj. !",
|
||||
'wl.newDxcc': 'NOUV DXCC', 'dxp.tab': 'DXpéditions', 'dxp.announced': 'Opérations annoncées', 'dxp.news': 'Actualités DX', 'dxp.source': 'depuis {name}', 'dxp.neededOnly': 'Seulement ce qu’il me manque', 'dxp.refresh': 'Actualiser', 'dxp.none': 'Aucune opération annoncée — ou le flux n’a pas pu être lu.', 'dxp.noNews': 'Aucune actualité.', 'dxp.onAir': 'EN COURS', 'dxp.watch': 'Surveiller', 'dxp.watched': 'Surveillé', 'dxp.watchTip': 'Ajouter à la watchlist', 'dxp.open': 'Ouvrir', 'wlnote.added': '{call} ajouté à la watchlist', 'wlnote.removed': '{call} retiré de la watchlist', 'wlnote.hint': 'Ses spots sont mis en évidence dans le cluster.', 'wl.worked': 'Contacté', 'wl.needed': 'Manquant !', 'wl.todayOk': "Auj. ✓", 'wl.workToday': "À faire auj. !",
|
||||
'wl.spotTip': "Clic : remplir l'indicatif · double-clic : régler la radio et travailler",
|
||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
||||
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
|
||||
|
||||
Reference in New Issue
Block a user