feat: Alerts are persistent for the 3 last ones for 10minutes
This commit is contained in:
@@ -793,6 +793,19 @@ export default function App() {
|
||||
|
||||
const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal
|
||||
|
||||
// Persistent spot-alert tray: keep the last few fired alerts visible (they used
|
||||
// to vanish with the 3 s toast, lost if you were on another window). Each stays
|
||||
// ~10 min and is clickable to tune the rig to its freq/mode.
|
||||
type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
|
||||
const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
|
||||
const ALERT_TTL_MS = 10 * 60 * 1000;
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setRecentAlerts((prev) => { const cut = Date.now() - ALERT_TTL_MS; const n = prev.filter((a) => a.at >= cut); return n.length === prev.length ? prev : n; });
|
||||
}, 20000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
|
||||
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
|
||||
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
|
||||
@@ -1197,9 +1210,17 @@ export default function App() {
|
||||
if (p?.visual) {
|
||||
const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
|
||||
showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? ` — ${p.country}` : ''}`);
|
||||
// Also keep it in the persistent tray (clickable, ~10 min).
|
||||
const a: RecentAlert = {
|
||||
id: Date.now() + Math.random(), rule, call, band,
|
||||
mode: String(p?.mode ?? ''), freq_hz: Number(p?.freq_hz ?? 0),
|
||||
country: String(p?.country ?? ''), comment: String(p?.comment ?? ''), at: Date.now(),
|
||||
};
|
||||
setRecentAlerts((prev) => [a, ...prev.filter((x) => !(x.call === a.call && x.band === a.band))].slice(0, 3));
|
||||
}
|
||||
});
|
||||
return () => { off(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showToast]);
|
||||
|
||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||
@@ -4364,6 +4385,33 @@ export default function App() {
|
||||
<AlertsModal onClose={() => setAlertsOpen(false)} bands={bands} modes={modes} countries={countries} />
|
||||
)}
|
||||
|
||||
{/* Persistent spot-alert tray (top-right). Each card stays ~10 min; click it
|
||||
to tune the rig to its freq/mode + fill the callsign; × dismisses it. */}
|
||||
{recentAlerts.length > 0 && (
|
||||
<div className="fixed top-16 right-3 z-[60] flex flex-col gap-2 w-72 max-w-[85vw]">
|
||||
{recentAlerts.map((a) => (
|
||||
<div key={a.id} className="flex items-stretch rounded-lg border border-warning/50 bg-card shadow-lg overflow-hidden">
|
||||
<button type="button"
|
||||
title={t('alert.tuneHint')}
|
||||
onClick={() => handleSpotClick({ comment: a.comment, freq_hz: a.freq_hz, band: a.band, dx_call: a.call })}
|
||||
className="flex-1 min-w-0 text-left px-3 py-2 hover:bg-warning/10 transition-colors">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>🔔</span>
|
||||
<span className="font-bold text-sm truncate">{a.call}</span>
|
||||
{a.freq_hz > 0 && <span className="ml-auto shrink-0 text-[11px] font-mono tabular-nums text-warning">{(a.freq_hz / 1e6).toFixed(3)}</span>}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{a.rule}{a.band ? ` · ${a.band}` : ''}{a.mode ? ` · ${a.mode}` : ''}{a.country ? ` · ${a.country}` : ''}
|
||||
</div>
|
||||
</button>
|
||||
<button type="button" title={t('alert.dismiss')}
|
||||
onClick={() => setRecentAlerts((prev) => prev.filter((x) => x.id !== a.id))}
|
||||
className="shrink-0 px-2 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDuplicates && (
|
||||
<DuplicatesModal onClose={() => setShowDuplicates(false)} onDeleted={() => refresh()} />
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,7 @@ const en: Dict = {
|
||||
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
|
||||
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
|
||||
'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',
|
||||
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
|
||||
// Duplicates modal
|
||||
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
|
||||
@@ -228,6 +229,7 @@ const fr: Dict = {
|
||||
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
|
||||
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
|
||||
'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',
|
||||
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
|
||||
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
|
||||
'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.',
|
||||
|
||||
Reference in New Issue
Block a user