'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');
const newsAudio = new Audio('/news.mp3');
let notifTimer = null;
function showNotif(message, audio = softAlarmAudio) {
notifMessage.textContent = message;
// Re-déclencher l'animation de la barre
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;
}, 10_000);
}
// ── 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) {
showNotif(`Nouvelle actualité géopolitique : ${newGeoItems[0].title}`, newsAudio);
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);
}
});
}
// ── 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);