Tried it, looked at it, took it out. A headline names no band, so the only honest verdict is entity-level — and a pane where every row reads NEW DXCC or worked says nothing an operator can act on. Two words repeated forty times is noise wearing the clothes of information. The announcements pane keeps its badge, where the announced bands and modes make the verdict specific enough to be worth a colour. The news pane keeps what it is good at: what is happening, and a Watch button. Changelog entry withdrawn with it — it never shipped.
241 lines
12 KiB
TypeScript
241 lines
12 KiB
TypeScript
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) => {
|
|
const newsCalls = n.calls ?? [];
|
|
const newsAllWatched = newsCalls.length > 0 && newsCalls.every((c) => watched.has(c.toUpperCase()));
|
|
return (
|
|
<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>
|
|
{/* The callsigns are shown, not clicked: watching is the same
|
|
one button as an announcement, so the gesture is learned
|
|
once for the whole tab. */}
|
|
<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) => (
|
|
<span key={c} className={cn('font-mono text-[10px]',
|
|
watched.has(c.toUpperCase()) ? 'text-warning' : 'text-info')}>
|
|
{c}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="mt-1.5 flex items-center gap-2">
|
|
<Button variant={newsAllWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
|
|
disabled={newsCalls.length === 0 || newsAllWatched}
|
|
onClick={() => addAll(newsCalls)}
|
|
title={t('dxp.watchTip')}>
|
|
<Star className={cn('size-3', newsAllWatched && 'fill-current text-warning')} />
|
|
{newsAllWatched ? t('dxp.watched') : t('dxp.watch')}
|
|
</Button>
|
|
{n.link && (
|
|
<button type="button" onClick={() => BrowserOpenURL(n.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>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|