feat: When in MySQL if loosing db connection contacts can still be saved

they will be synchronized with the db once connected again.
This commit is contained in:
2026-07-13 00:16:58 +02:00
parent a00817b93e
commit b59c6856bd
2 changed files with 57 additions and 24 deletions
+55 -24
View File
@@ -555,13 +555,20 @@ export default function App() {
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; } if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
advanceToast(); // skip to the next queued toast (or clear if none) advanceToast(); // skip to the next queued toast (or clear if none)
}, [advanceToast]); }, [advanceToast]);
// Status-bar message expanded into a popover (long errors — a TQSL or Club Log
// failure — don't fit on one line, and truncating them with no way to read the
// rest is useless).
const [msgOpen, setMsgOpen] = useState(false);
// Reset when the message goes away, or the NEXT one would pop open by itself.
useEffect(() => { if (!error && !toast) setMsgOpen(false); }, [error, toast]);
// Error banners auto-dismiss after a few seconds (longer than toasts since // Error banners auto-dismiss after a few seconds (longer than toasts since
// they may be multi-line). The X button still closes them immediately. // they may be multi-line). The X button still closes them immediately. Do NOT
// dismiss while the operator has it expanded and is reading it.
useEffect(() => { useEffect(() => {
if (!error) return; if (!error || msgOpen) return;
const t = window.setTimeout(() => setError(''), 6000); const t = window.setTimeout(() => setError(''), 6000);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [error]); }, [error, msgOpen]);
// True while the QSO recorder is capturing the current contact (set when we // True while the QSO recorder is capturing the current contact (set when we
// leave the callsign field, cleared on log/cancel). Drives the REC badge. // leave the callsign field, cleared on log/cancel). Drives the REC badge.
const [recording, setRecording] = useState(false); const [recording, setRecording] = useState(false);
@@ -3358,25 +3365,8 @@ export default function App() {
<Menubar menus={menus} onAction={handleMenu} /> <Menubar menus={menus} onAction={handleMenu} />
<div className="relative flex items-center justify-center gap-2 font-mono"> <div className="relative flex items-center justify-center gap-2 font-mono">
{/* Transient toast / error, in the empty band between the menu and {/* Toasts and errors live in the STATUS BAR at the bottom now — the
the frequency (left of centre). Single-line + truncated. */} header band was too narrow and long messages were cut off. */}
{(error || toast) && (
<div className="absolute left-1 top-1/2 -translate-y-1/2 z-20 flex items-center max-w-[min(42vw,560px)] font-sans">
{error ? (
<div className="flex items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/10 text-destructive px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<AlertCircle className="size-3.5 shrink-0" />
<span className="truncate" title={error}>{error}</span>
<button className="shrink-0 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3" /></button>
</div>
) : (
<div className="flex items-center gap-1.5 rounded-md border border-success-border bg-success-muted text-success-muted-foreground px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<Satellite className="size-3.5 shrink-0" />
<span className="truncate" title={toast}>{toast}</span>
<button className="shrink-0 text-success hover:text-success" onClick={dismissToast}><X className="size-3" /></button>
</div>
)}
</div>
)}
<div className="flex flex-col items-end leading-none"> <div className="flex flex-col items-end leading-none">
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span> <span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
{catState.split && rxFreqMhz && ( {catState.split && rxFreqMhz && (
@@ -4507,7 +4497,7 @@ export default function App() {
</button> </button>
); );
return ( return (
<footer className="flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0"> <footer className="relative flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong> QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
</span> </span>
@@ -4526,7 +4516,48 @@ export default function App() {
disabled={!rotatorHeading.enabled} disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }} onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/> />
<div className="flex-1" /> {/* Toasts / errors: the status bar's free space is far wider than the
header band they used to sit in. Still one line (the bar is 28px),
but CLICK opens the full text wrapped — long messages (a TQSL or
Club Log failure) are no longer cut off with no way to read them. */}
<div className="flex-1 min-w-0 flex items-center gap-1 px-1">
{(error || toast) && (
<>
<button
type="button"
onClick={() => setMsgOpen((o) => !o)}
title={t('msg.expand')}
className={cn(
'flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] min-w-0 max-w-full animate-in fade-in',
error
? 'border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/20'
: 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted/70',
)}
>
{error ? <AlertCircle className="size-3.5 shrink-0" /> : <Satellite className="size-3.5 shrink-0" />}
<span className="truncate">{error || toast}</span>
</button>
<button
type="button"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => { setMsgOpen(false); if (error) setError(''); else dismissToast(); }}
>
<X className="size-3" />
</button>
{msgOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMsgOpen(false)} />
<div className="absolute bottom-7 left-2 z-50 w-[min(70vw,760px)] rounded-lg border border-border bg-card shadow-xl p-3">
<p className={cn('text-xs whitespace-pre-wrap break-words leading-relaxed',
error ? 'text-destructive' : 'text-foreground')}>
{error || toast}
</p>
</div>
</>
)}
</>
)}
</div>
{dbConn && ( {dbConn && (
<button <button
type="button" type="button"
+2
View File
@@ -46,6 +46,7 @@ const en: Dict = {
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.', 'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
'lang.english': 'English', 'lang.french': 'Français', 'lang.english': 'English', 'lang.french': 'Français',
'settings.language': 'Language', 'settings.languageHint': 'Interface language.', 'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
'msg.expand': 'Click to read the full message',
'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically', 'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically',
'offline.tip': '{n} QSO(s) waiting — the database is unreachable', 'offline.tip': '{n} QSO(s) waiting — the database is unreachable',
'offline.title': 'Offline — {n} QSO(s) waiting', 'offline.title': 'Offline — {n} QSO(s) waiting',
@@ -264,6 +265,7 @@ const fr: Dict = {
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.', 'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
'lang.english': 'English', 'lang.french': 'Français', 'lang.english': 'English', 'lang.french': 'Français',
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.", 'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
'msg.expand': 'Cliquer pour lire le message en entier',
'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique', 'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique',
'offline.tip': '{n} QSO en attente — la base est injoignable', 'offline.tip': '{n} QSO en attente — la base est injoignable',
'offline.title': 'Hors ligne — {n} QSO en attente', 'offline.title': 'Hors ligne — {n} QSO en attente',