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
|
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).
|
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
|
||||||
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
|
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
|
||||||
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
|
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
|
||||||
@@ -1197,9 +1210,17 @@ export default function App() {
|
|||||||
if (p?.visual) {
|
if (p?.visual) {
|
||||||
const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
|
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}` : ''}`);
|
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(); };
|
return () => { off(); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
// 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} />
|
<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 && (
|
{showDuplicates && (
|
||||||
<DuplicatesModal onClose={() => setShowDuplicates(false)} onDeleted={() => refresh()} />
|
<DuplicatesModal onClose={() => setShowDuplicates(false)} onDeleted={() => refresh()} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const en: Dict = {
|
|||||||
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
|
'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.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',
|
'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…',
|
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
|
||||||
// Duplicates modal
|
// Duplicates modal
|
||||||
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
|
'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.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.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',
|
'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…',
|
'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.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.',
|
'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