dashboard/public/app.js

522 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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`, null);
playKillstreak(msg.streak || 1);
} 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');
// ── Killstreak sounds ─────────────────────────────────────────────────────────
const killstreakSounds = [
null, // index 0 inutilisé
[new Audio('/sound_effects/first-blood-1.mp3'), new Audio('/sound_effects/first-blood-2.mp3')],
[new Audio('/sound_effects/double-kill-1.mp3'), new Audio('/sound_effects/double-kill-2.mp3')],
[new Audio('/sound_effects/triple-kill-1.mp3'), new Audio('/sound_effects/triple-kill-2.mp3')],
[new Audio('/sound_effects/monsterkill.mp3')],
];
const godlikeAudio = new Audio('/sound_effects/godlike.mp3');
function playKillstreak(streak) {
const pool = streak >= 5 ? [godlikeAudio] : (killstreakSounds[streak] || killstreakSounds[1]);
const snd = pool[Math.floor(Math.random() * pool.length)];
snd.currentTime = 0;
snd.play().catch(err => console.error('killstreak audio:', err));
}
// ── 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeAttr(str) {
return String(str).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
document.addEventListener('DOMContentLoaded', init);