(function() { var CURRENT_BIB = null; function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } function formatIsoToTime(iso) { if (!iso) return ''; try { var d = new Date(iso); return d.toLocaleTimeString('ru-RU', { hour12: false }); } catch (e) { return iso; } } function renderFeedItems(items) { var feedEl = document.getElementById('feed'); if (!feedEl) return; if (!items || !items.length) { feedEl.innerHTML = '
Пока нет проходов по КП
'; return; } var html = ''; for (var i = 0; i < items.length; i++) { var it = items[i]; html += '
'; html += '
'; html += '
'; html += ' #' + (it.bib || '') + ''; html += ' ' + (it.name || '') + ''; if (it.cp_code) {{ html += ' CP ' + it.cp_code + (it.lap_no != null ? (' · Lap ' + it.lap_no) : '') + ''; }} else {{ html += ' CP ?'; }} html += '
'; if (it.team) {{ html += '
' + it.team + '
'; }} html += '
'; html += '
' + (it.ts ? formatIsoToTime(it.ts) : '') + '
'; html += '
'; } feedEl.innerHTML = html; } async function loadParticipant(EVENT_ID, bib) { CURRENT_BIB = bib; var card = document.getElementById('participant-card'); if (!card) return; card.innerHTML = 'Загрузка...'; try { var resp = await fetch('/public/api/spectator/participant?event_id=' + EVENT_ID + '&bib=' + encodeURIComponent(bib)); if (!resp.ok) { card.innerHTML = 'Участник не найден'; return; } var data = await resp.json(); var p = data.participant; var dist = data.distance || {}; var res = data.result || {}; // при желании можно динамически обновить заголовок события var subtitle = document.querySelector('.subtitle'); if (subtitle && data.event && data.event.name) {{ subtitle.textContent = data.event.name + ' · Event ID=' + data.event.id; }} var statusLabel = data.status || 'UNKNOWN'; var statusColor = '#4b5563'; if (statusLabel.indexOf('FINISH') === 0) statusColor = '#16a34a'; else if (statusLabel.indexOf('DNS') === 0 || statusLabel.indexOf('DNF') === 0) statusColor = '#b91c1c'; else if (statusLabel) statusColor = '#1d4ed8'; var lastSplit = data.last_split; var eta = data.eta_finish; var html = ''; html += '
#' + (p.bib || '') + ' ' + (p.name || '') + '
'; html += '
'; html += (dist.name ? ('Дистанция: ' + dist.name) : 'Дистанция: —'); if (p.team) {{ html += ' · Команда: ' + p.team; }} if (p.age != null) {{ html += ' · Возраст: ' + p.age; }} html += '
'; // Верхние "пилюли" html += '
'; html += '
Статус' + (statusLabel || '—') + '
'; if (data.laps_done != null && dist.total_laps != null) {{ html += '
Круги' + data.laps_done + ' / ' + dist.total_laps + '
'; }} if (eta) {{ html += '
ETA финиша' + eta + '
'; }} html += '
'; // Сетка статов html += '
'; html += '
'; html += '
Старт
'; html += '
' + (res.start_ts ? formatIsoToTime(res.start_ts) : '—') + '
'; html += '
'; html += '
'; html += '
Финиш
'; html += '
' + (res.finish_ts ? formatIsoToTime(res.finish_ts) : '—') + '
'; html += '
'; html += '
'; html += '
Итоговое время
'; html += '
' + (res.elapsed || '—') + '
'; html += '
'; html += '
'; html += '
Последний КП
'; if (lastSplit && lastSplit.cp_code) {{ var cp = 'CP ' + lastSplit.cp_code; if (lastSplit.lap_no != null) {{ cp += ' · Lap ' + lastSplit.lap_no; }} html += '
' + cp + '
'; }} else {{ html += '
'; }} html += '
'; html += '
'; // Время последнего КП снизу html += '
'; html += 'Последний проход по КП в '; if (lastSplit && lastSplit.ts) {{ html += formatIsoToTime(lastSplit.ts); }} else {{ html += '—'; }} html += '
'; card.innerHTML = html; } catch (e) { console.error(e); card.innerHTML = 'Ошибка загрузки данных'; } } async function loadFeedOnce(EVENT_ID) { var feedEl = document.getElementById('feed'); if (!feedEl) return; try { var resp = await fetch('/public/api/spectator/feed?event_id=' + EVENT_ID + '&limit=30'); if (!resp.ok) { feedEl.innerHTML = '
Ошибка загрузки ленты
'; return; } var data = await resp.json(); renderFeedItems(data.items || []); } catch (e) { console.error(e); feedEl.innerHTML = '
Ошибка загрузки ленты
'; } } async function subscribePush(EVENT_ID) { if (!('Notification' in window) || !('serviceWorker' in navigator) || !('PushManager' in window)) { alert('Браузер не поддерживает push-уведомления'); return; } if (!CURRENT_BIB) { alert('Сначала выберите участника (BIB)'); return; } const permission = await Notification.requestPermission(); if (permission !== 'granted') { alert('Уведомления не разрешены'); return; } const reg = await navigator.serviceWorker.ready; // Получаем публичный VAPID-ключ const cfgResp = await fetch('/public/api/push/config'); const cfg = await cfgResp.json(); const vapidKey = cfg.vapid_public_key; if (!vapidKey) { alert('Сервер не настроен для push (нет VAPID ключа)'); return; } const applicationServerKey = urlBase64ToUint8Array(vapidKey); const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }); await fetch('/public/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event_id: EVENT_ID, bib: CURRENT_BIB, subscription: sub }) }); alert('Подписка на уведомления оформлена'); } document.addEventListener('DOMContentLoaded', function() { var body = document.body; var eventIdAttr = body ? body.getAttribute('data-event-id') : null; var EVENT_ID = eventIdAttr ? parseInt(eventIdAttr, 10) : null; if (!EVENT_ID) { console.warn('No EVENT_ID on page'); return; } // --- Автозагрузка участника по BIB из URL --- var params = new URLSearchParams(window.location.search); var urlBib = params.get('bib'); if (urlBib) { CURRENT_BIB = urlBib; var bibInput = document.getElementById('bib-input'); if (bibInput) bibInput.value = urlBib; loadParticipant(EVENT_ID, urlBib); } var bibInput = document.getElementById('bib-input'); var bibBtn = document.getElementById('bib-btn'); var notifyBtn = document.getElementById('notify-btn'); if (notifyBtn) { notifyBtn.addEventListener('click', function () { subscribePush(EVENT_ID); }); } if (bibBtn) { bibBtn.addEventListener('click', function() { if (!bibInput) return; var v = (bibInput.value || '').trim(); if (!v) return; loadParticipant(EVENT_ID, v); }); } if (bibInput) { bibInput.addEventListener('keyup', function(e) { if (e.key === 'Enter') { var v = (bibInput.value || '').trim(); if (v) loadParticipant(EVENT_ID, v); } }); } // первичная загрузка ленты loadFeedOnce(EVENT_ID); if ('EventSource' in window) { var es = new EventSource('/public/api/spectator/feed_sse?event_id=' + EVENT_ID); es.onmessage = function(e) { try { var data = JSON.parse(e.data); renderFeedItems(data.items || []); } catch (err) { console.error('SSE parse error', err); } }; es.onerror = function(e) { console.error('SSE error', e); }; } else { // fallback: периодический опрос setInterval(function() { loadFeedOnce(EVENT_ID); }, 10000); } }); })();