503 lines
18 KiB
JavaScript
503 lines
18 KiB
JavaScript
'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`);
|
||
} else if (msg.type === 'geo_news') {
|
||
showGeoBanner(msg.title, msg.link);
|
||
} else if (msg.type === 'anssi_news') {
|
||
showNotif(`Nouveau bulletin ANSSI : ${msg.title}`);
|
||
}
|
||
});
|
||
|
||
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 = `<img src="${escapeAttr(image)}" alt="">`;
|
||
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');
|
||
|
||
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 = '<li class="feed-loading">Aucun bulletin</li>';
|
||
return;
|
||
}
|
||
|
||
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 = `
|
||
<a href="${escapeAttr(item.link)}" target="_blank" rel="noopener noreferrer">${escapeHtml(item.title)}</a>
|
||
${date ? `<span class="anssi-date">${escapeHtml(date)}</span>` : ''}
|
||
`;
|
||
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 = `<li class="feed-loading">Erreur : ${escapeHtml(err.message)}</li>`;
|
||
}
|
||
}
|
||
|
||
// ── Géopolitique feed ─────────────────────────────────────────────────────────
|
||
|
||
const geoList = document.getElementById('geo-list');
|
||
const geoStatus = document.getElementById('geo-status');
|
||
|
||
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 = '<li class="feed-loading">Aucune actualité</li>';
|
||
return;
|
||
}
|
||
|
||
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 = `
|
||
<a href="${escapeAttr(item.link)}" target="_blank" rel="noopener noreferrer">${escapeHtml(item.title)}</a>
|
||
${date ? `<span class="anssi-date">${escapeHtml(item.source ? item.source + ' · ' : '')}${escapeHtml(date)}</span>` : ''}
|
||
`;
|
||
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 = `<li class="feed-loading">Erreur : ${escapeHtml(err.message)}</li>`;
|
||
}
|
||
}
|
||
|
||
// ── 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 = '<li class="feed-loading">Aucun cours aujourd\'hui</li>';
|
||
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 = `
|
||
<span class="cal-time">${escapeHtml(timeStr)}</span>
|
||
<span class="cal-title">${escapeHtml(event.title)}</span>
|
||
${event.location ? `<span class="cal-location">${escapeHtml(event.location)}</span>` : ''}
|
||
`;
|
||
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 = `<li class="feed-loading">Erreur : ${escapeHtml(err.message)}</li>`;
|
||
}
|
||
}
|
||
|
||
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 = '<li class="feed-loading">Aucun joueur</li>';
|
||
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 = `
|
||
<span class="rootme-rank ${rankClass(idx)}">#${idx + 1}</span>
|
||
<span class="rootme-login">${escapeHtml(user.login)}</span>
|
||
<span class="rootme-score">${user.score.toLocaleString('fr-FR')} pts</span>
|
||
`;
|
||
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 = `<li class="feed-loading">Erreur : ${escapeHtml(err.message)}</li>`;
|
||
}
|
||
}
|
||
|
||
// ── 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, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function escapeAttr(str) {
|
||
return String(str).replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', init);
|