'use strict'; // ── WebSocket ──────────────────────────────────────────────────────────────── let ws; let wsReconnectTimer; function connectWS() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${proto}//${location.host}`); ws.addEventListener('open', () => { setWsStatus('CONNECTÉ', 'ok'); updateInfoWS('CONNECTÉ'); if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; } }); ws.addEventListener('message', evt => { let msg; try { msg = JSON.parse(evt.data); } catch { return; } if (msg.type === 'alert') { showAlert(msg.message, msg.html, msg.image); } else if (msg.type === 'dismiss') { hideAlert(); } else if (msg.type === 'rootme_update') { renderRootme(msg.ranking); } else if (msg.type === 'rootme_flag') { renderRootme(rootmeCache); showNotif(`FLAG ! ${msg.login} +${msg.gained} PTS — TOTAL : ${msg.newScore} PTS`); } }); ws.addEventListener('close', () => { setWsStatus('DÉCONNECTÉ', 'err'); updateInfoWS('DÉCONNECTÉ'); wsReconnectTimer = setTimeout(connectWS, 3000); }); ws.addEventListener('error', () => { ws.close(); }); } function setWsStatus(text, cls) { const el = document.getElementById('ws-status'); el.textContent = text; el.className = 'widget-status ' + (cls || ''); } function updateInfoWS(text) { document.getElementById('info-ws').textContent = text; } // ── Alert overlay ──────────────────────────────────────────────────────────── const overlay = document.getElementById('alert-overlay'); const alertMessageEl = document.getElementById('alert-message'); const alertHtmlEl = document.getElementById('alert-html'); const alertIconEl = document.getElementById('alert-icon'); const alertImageEl = document.getElementById('alert-image'); const infoAlert = document.getElementById('info-alert'); // ── Notification (ANSSI / geo) ──────────────────────────────────────────────── const notifOverlay = document.getElementById('notif-overlay'); const notifMessage = document.getElementById('notif-message'); const notifBarInner = document.getElementById('notif-bar-inner'); const softAlarmAudio = new Audio('/soft_alarm.mp3'); let notifTimer = null; function showNotif(message, audio = softAlarmAudio, duration = 10_000) { notifMessage.textContent = message; // Re-déclencher l'animation de la barre notifBarInner.style.setProperty('--notif-duration', `${duration / 1000}s`); notifBarInner.style.animation = 'none'; void notifBarInner.offsetWidth; notifBarInner.style.animation = ''; notifOverlay.style.animation = 'none'; void notifOverlay.offsetWidth; notifOverlay.style.animation = ''; notifOverlay.classList.remove('hidden'); if (audio) { audio.currentTime = 0; audio.play().catch(err => console.error('notif audio:', err)); } if (notifTimer) clearTimeout(notifTimer); notifTimer = setTimeout(() => { notifOverlay.classList.add('hidden'); notifTimer = null; }, duration); } // ── Geo news bottom banner ──────────────────────────────────────────────────── const geoBanner = document.getElementById('geo-banner'); const geoBannerLink = document.getElementById('geo-banner-link'); let geoBannerTimer = null; function showGeoBanner(title, link, duration = 15_000) { geoBannerLink.textContent = title; geoBannerLink.href = link || '#'; geoBanner.style.animation = 'none'; void geoBanner.offsetWidth; geoBanner.style.animation = ''; geoBanner.classList.remove('hidden'); if (geoBannerTimer) clearTimeout(geoBannerTimer); geoBannerTimer = setTimeout(() => { geoBanner.classList.add('hidden'); geoBannerTimer = null; }, duration); } // ── Alarm sound ─────────────────────────────────────────────────────────────── const alarmAudio = new Audio('/alert.mp3'); function playAlertSound() { alarmAudio.currentTime = 0; alarmAudio.play().catch(err => console.error('Audio play failed:', err)); } function stopAlertSound() { alarmAudio.pause(); alarmAudio.currentTime = 0; } // ── Alert overlay ───────────────────────────────────────────────────────────── let autoDismissTimer = null; function showAlert(message, html, image) { // Image if (image && image.trim()) { alertImageEl.innerHTML = ``; alertIconEl.style.display = 'none'; } else { alertImageEl.innerHTML = ''; alertIconEl.style.display = ''; } // Text / HTML alertMessageEl.textContent = message || ''; alertMessageEl.style.display = (message && !image) ? '' : (message ? '' : 'none'); alertHtmlEl.innerHTML = (html && html.trim()) ? html : ''; // Show overlay overlay.classList.remove('hidden'); overlay.style.animation = 'none'; void overlay.offsetWidth; overlay.style.animation = ''; infoAlert.textContent = 'ACTIVE'; infoAlert.style.color = 'var(--red)'; playAlertSound(); if (autoDismissTimer) clearTimeout(autoDismissTimer); autoDismissTimer = setTimeout(hideAlert, 60_000); } function hideAlert() { if (autoDismissTimer) { clearTimeout(autoDismissTimer); autoDismissTimer = null; } overlay.classList.add('hidden'); stopAlertSound(); infoAlert.textContent = 'INACTIVE'; infoAlert.style.color = ''; } // ── Clock ──────────────────────────────────────────────────────────────────── const clockTime = document.getElementById('clock-time'); const clockDate = document.getElementById('clock-date'); const headerClock = document.getElementById('header-clock'); const infoHost = document.getElementById('info-host'); const DAYS = ['Dimanche','Lundi','Mardi','Mercredi','Jeudi','Vendredi','Samedi']; const MONTHS = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc']; function pad(n) { return String(n).padStart(2, '0'); } function updateClock() { const now = new Date(); const h = pad(now.getHours()); const m = pad(now.getMinutes()); const s = pad(now.getSeconds()); const timeStr = `${h}:${m}:${s}`; const dateStr = `${DAYS[now.getDay()]} ${pad(now.getDate())} ${MONTHS[now.getMonth()]} ${now.getFullYear()}`; clockTime.textContent = timeStr; clockDate.textContent = dateStr; headerClock.textContent = `${dateStr} ${timeStr}`; } // ── ANSSI / CERT-FR feed ───────────────────────────────────────────────────── const anssiList = document.getElementById('anssi-list'); const anssiStatus = document.getElementById('anssi-status'); let seenAnssiLinks = null; async function loadAnssi() { anssiStatus.textContent = '...'; anssiStatus.className = 'widget-status'; try { const resp = await fetch('/api/feeds/anssi'); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const items = await resp.json(); anssiList.innerHTML = ''; if (!items.length) { anssiList.innerHTML = '
  • Aucun bulletin
  • '; return; } const currentAnssiLinks = new Set(items.map(i => i.link)); if (seenAnssiLinks === null) { seenAnssiLinks = currentAnssiLinks; } else { const newItems = items.filter(i => !seenAnssiLinks.has(i.link)); if (newItems.length) { showNotif(`Nouveau bulletin ANSSI : ${newItems[0].title}`); seenAnssiLinks = currentAnssiLinks; } } items.forEach(item => { const li = document.createElement('li'); li.className = 'anssi-item'; const date = item.pubDate ? new Date(item.pubDate).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }) : ''; li.innerHTML = ` ${escapeHtml(item.title)} ${date ? `${escapeHtml(date)}` : ''} `; anssiList.appendChild(li); }); anssiStatus.textContent = `${items.length} bulletins`; anssiStatus.className = 'widget-status ok'; } catch (err) { anssiStatus.textContent = 'ERREUR'; anssiStatus.className = 'widget-status err'; anssiList.innerHTML = `
  • Erreur : ${escapeHtml(err.message)}
  • `; } } // ── Géopolitique feed ───────────────────────────────────────────────────────── const geoList = document.getElementById('geo-list'); const geoStatus = document.getElementById('geo-status'); let seenGeoLinks = null; async function loadGeo() { geoStatus.textContent = '...'; geoStatus.className = 'widget-status'; try { const resp = await fetch('/api/feeds/geo'); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const items = await resp.json(); geoList.innerHTML = ''; if (!items.length) { geoList.innerHTML = '
  • Aucune actualité
  • '; return; } const currentLinks = new Set(items.map(i => i.link)); if (seenGeoLinks === null) { // Premier chargement : on mémorise sans jouer seenGeoLinks = currentLinks; } else { const newGeoItems = items.filter(i => !seenGeoLinks.has(i.link)); if (newGeoItems.length) { showGeoBanner(newGeoItems[0].title, newGeoItems[0].link); seenGeoLinks = currentLinks; } } items.forEach(item => { const li = document.createElement('li'); li.className = 'anssi-item'; const date = item.pubDate ? new Date(item.pubDate).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) : ''; li.innerHTML = ` ${escapeHtml(item.title)} ${date ? `${escapeHtml(item.source ? item.source + ' · ' : '')}${escapeHtml(date)}` : ''} `; geoList.appendChild(li); }); geoStatus.textContent = `${items.length} articles`; geoStatus.className = 'widget-status ok'; } catch (err) { geoStatus.textContent = 'ERREUR'; geoStatus.className = 'widget-status err'; geoList.innerHTML = `
  • Erreur : ${escapeHtml(err.message)}
  • `; } } // ── Calendar ────────────────────────────────────────────────────────────────── const calList = document.getElementById('cal-list'); const calStatus = document.getElementById('cal-status'); let calEvents = []; const notifiedCourses = new Set(); const DAY_SHORT = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']; function renderCalendar() { calList.innerHTML = ''; const now = new Date(); const todayStart = new Date(now); todayStart.setHours(0, 0, 0, 0); const todayEnd = new Date(now); todayEnd.setHours(23, 59, 59, 999); const todayEvents = calEvents.filter(e => { const s = new Date(e.start); return s >= todayStart && s <= todayEnd; }); if (!todayEvents.length) { calList.innerHTML = '
  • Aucun cours aujourd\'hui
  • '; return; } todayEvents.forEach(event => { const start = new Date(event.start); const end = new Date(event.end); const li = document.createElement('li'); li.className = 'cal-item' + (now >= start && now < end ? ' active' : '') + (now >= end ? ' past' : ''); const timeStr = `${pad(start.getHours())}:${pad(start.getMinutes())} – ${pad(end.getHours())}:${pad(end.getMinutes())}`; li.innerHTML = ` ${escapeHtml(timeStr)} ${escapeHtml(event.title)} ${event.location ? `${escapeHtml(event.location)}` : ''} `; calList.appendChild(li); }); } async function loadCalendar() { calStatus.textContent = '...'; calStatus.className = 'widget-status'; try { const resp = await fetch('/api/calendar'); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); calEvents = await resp.json(); renderCalendar(); calStatus.textContent = `${calEvents.length} cours`; calStatus.className = 'widget-status ok'; } catch (err) { calStatus.textContent = 'ERREUR'; calStatus.className = 'widget-status err'; calList.innerHTML = `
  • Erreur : ${escapeHtml(err.message)}
  • `; } } function checkUpcomingCourses() { const now = new Date(); calEvents.forEach(event => { const start = new Date(event.start); const diffMin = (start - now) / 60000; if (diffMin > 0 && diffMin <= 5 && !notifiedCourses.has(event.start)) { notifiedCourses.add(event.start); const loc = event.location ? ` — ${event.location}` : ''; showNotif(`COURS DANS ${Math.ceil(diffMin)} MIN : ${event.title}${loc}`, null, 60_000); } }); } // ── Root-me ranking ─────────────────────────────────────────────────────────── const rootmeList = document.getElementById('rootme-list'); const rootmeStatus = document.getElementById('rootme-status'); let rootmeCache = []; function renderRootme(ranking) { if (!ranking) return; rootmeCache = ranking; rootmeList.innerHTML = ''; if (!ranking.length) { rootmeList.innerHTML = '
  • Aucun joueur
  • '; rootmeStatus.textContent = '—'; rootmeStatus.className = 'widget-status'; return; } const rankClass = i => i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''; ranking.forEach((user, idx) => { const li = document.createElement('li'); li.className = 'rootme-item'; li.innerHTML = ` #${idx + 1} ${escapeHtml(user.login)} ${user.score.toLocaleString('fr-FR')} pts `; rootmeList.appendChild(li); }); rootmeStatus.textContent = `${ranking.length} joueur${ranking.length > 1 ? 's' : ''}`; rootmeStatus.className = 'widget-status ok'; } async function loadRootme() { rootmeStatus.textContent = '...'; rootmeStatus.className = 'widget-status'; try { const resp = await fetch('/api/rootme'); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const ranking = await resp.json(); renderRootme(ranking); } catch (err) { rootmeStatus.textContent = 'ERREUR'; rootmeStatus.className = 'widget-status err'; rootmeList.innerHTML = `
  • Erreur : ${escapeHtml(err.message)}
  • `; } } // ── Carousel ────────────────────────────────────────────────────────────────── const CAROUSEL_PAGES = [ ['widget-kaspersky', 'widget-calendar', 'widget-geo', 'widget-placeholder'], ['widget-kaspersky', 'widget-anssi', 'widget-geo', 'widget-clock'], ]; const ALL_CAROUSEL_IDS = [...new Set(CAROUSEL_PAGES.flat())]; const CAROUSEL_FADE_MS = 450; let carouselPage = 0; function applyCarouselInstant() { const visible = new Set(CAROUSEL_PAGES[carouselPage]); ALL_CAROUSEL_IDS.forEach(id => { const el = document.getElementById(id); if (!el) return; el.style.opacity = ''; el.style.display = visible.has(id) ? '' : 'none'; }); } function rotateCarousel() { const prevVisible = new Set(CAROUSEL_PAGES[carouselPage]); carouselPage = (carouselPage + 1) % CAROUSEL_PAGES.length; const nextVisible = new Set(CAROUSEL_PAGES[carouselPage]); const outgoing = ALL_CAROUSEL_IDS.filter(id => prevVisible.has(id) && !nextVisible.has(id)); const incoming = ALL_CAROUSEL_IDS.filter(id => !prevVisible.has(id) && nextVisible.has(id)); // Fade out outgoing.forEach(id => { document.getElementById(id).style.opacity = '0'; }); setTimeout(() => { // Hide outgoing, reveal incoming at opacity 0 then fade in outgoing.forEach(id => { const el = document.getElementById(id); el.style.display = 'none'; el.style.opacity = ''; }); incoming.forEach(id => { const el = document.getElementById(id); el.style.display = ''; el.style.opacity = '0'; el.getBoundingClientRect(); // force reflow el.style.opacity = ''; }); }, CAROUSEL_FADE_MS); } // ── Init ───────────────────────────────────────────────────────────────────── function init() { infoHost.textContent = location.host; applyCarouselInstant(); setInterval(rotateCarousel, 5 * 60 * 1000); connectWS(); updateClock(); setInterval(updateClock, 1000); loadAnssi(); loadGeo(); loadRootme(); loadCalendar(); setInterval(loadAnssi, 5 * 60 * 1000); setInterval(loadGeo, 5 * 60 * 1000); setInterval(loadCalendar, 30 * 60 * 1000); setInterval(renderCalendar, 60 * 1000); // re-render active/past state chaque minute setInterval(checkUpcomingCourses, 30 * 1000); checkUpcomingCourses(); // Root-me est mis à jour via WebSocket (rootme_update / rootme_flag) } // ── Helpers ─────────────────────────────────────────────────────────────────── function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function escapeAttr(str) { return String(str).replace(/"/g, '"').replace(/'/g, '''); } document.addEventListener('DOMContentLoaded', init);