diff --git a/public/index.html b/public/index.html index 601bccc..bd0acca 100644 --- a/public/index.html +++ b/public/index.html @@ -628,6 +628,30 @@ /* Touch highlight */ .option-card { -webkit-tap-highlight-color: rgba(0,212,255,0.12); } + + /* ── Connection overlay ─────────────────────────────────── */ + #wsOverlay { + display: none; + position: fixed; + inset: 0; + background: rgba(11,13,20,0.92); + z-index: 900; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 14px; + } + #wsOverlay.show { display: flex; } + #wsOverlay .spinner { + width: 40px; height: 40px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + #wsOverlay p { color: var(--text-muted); font-size: 0.85rem; text-align: center; } + #wsOverlay .sub { font-size: 0.72rem; color: var(--text-muted); opacity: 0.6; margin-top: -8px; } @@ -691,6 +715,13 @@ + +
+
+

Connection lost

+
Attempting to reconnect…
+
+
@@ -745,7 +776,14 @@ } // ── WebSocket ────────────────────────────────────────────── + const RECONNECT_BASE = 1000; // 1s initial + const RECONNECT_MAX = 30000; // 30s cap + let reconnectDelay = RECONNECT_BASE; + let reconnectTimer = null; + let offlineVoteQueue = []; // votes cast while disconnected + function connectWS() { + if (ws) { ws.onclose = ws.onerror = null; ws.close(); } const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${location.host}`; ws = new WebSocket(wsUrl); @@ -754,6 +792,17 @@ state.wsConnected = true; updateWsStatus('Connected'); document.getElementById('wsDot').classList.remove('offline'); + document.getElementById('wsOverlay').classList.remove('show'); + reconnectDelay = RECONNECT_BASE; // reset backoff + + // Replay queued votes + if (offlineVoteQueue.length > 0) { + const queue = [...offlineVoteQueue]; + offlineVoteQueue = []; + queue.forEach(msg => wsSend(msg)); + } + + showToast('✓ Reconnected', 'success'); }; ws.onmessage = (event) => { @@ -768,10 +817,7 @@ } else if (msg.type === 'vote_update') { msg.results.forEach(r => { const opt = state.options.find(o => o.id === r.id); - if (opt) { - opt.votes = r.votes; - opt.voters = r.voters; - } + if (opt) { opt.votes = r.votes; opt.voters = r.voters; } }); render(); } else if (msg.type === 'option_added' || msg.type === 'option_approved') { @@ -794,12 +840,25 @@ state.wsConnected = false; updateWsStatus('Reconnecting…'); document.getElementById('wsDot').classList.add('offline'); - setTimeout(connectWS, 3000); + document.getElementById('wsOverlay').classList.add('show'); + clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { + reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX); + connectWS(); + }, reconnectDelay); }; } function wsSend(obj) { - if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(obj)); + } else { + // Queue vote if offline + if (obj.type === 'vote' && !offlineVoteQueue.find(m => + m.type === 'vote' && m.optionId === obj.optionId && m.voterName === obj.voterName)) { + offlineVoteQueue.push(obj); + } + } } function updateWsStatus(text) {