Fusion #69 : Alertes unifiees (static/js/draco-alertes.js)
This commit is contained in:
@@ -0,0 +1,427 @@
|
|||||||
|
/* Alertes unifiées dracodev — Fusion #69 (chantier 2).
|
||||||
|
*
|
||||||
|
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
|
||||||
|
* ouverture du panneau (et au chargement, pour la pastille), le module
|
||||||
|
* interroge les API de notifications réellement exposées par chaque
|
||||||
|
* application, avec les identifiants du navigateur (credentials:
|
||||||
|
* "include"). Aucune alerte n'est inventée : une source injoignable
|
||||||
|
* ou non authentifiée affiche son état réel (« Connexion requise »)
|
||||||
|
* avec un lien profond vers sa page native, jamais du vide déguisé.
|
||||||
|
*
|
||||||
|
* Sources réelles (audit #69, 2026-10-28) :
|
||||||
|
* - Flowdeck : GET {base}/api/notifications?limit=50
|
||||||
|
* -> { notifications: [{id, ntype, title, message, url, is_read,
|
||||||
|
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
|
||||||
|
* - TikTak : GET {base}/api/notifications
|
||||||
|
* -> [{id, kind, title, body, task_id, is_read, created_at}] ;
|
||||||
|
* active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
|
||||||
|
* base + "/notifications".
|
||||||
|
* - Kanbante : GET {base}/api/events?limit=100
|
||||||
|
* -> { events: [{repo_full_name, issue_number, event_type,
|
||||||
|
* from_column, to_column, created_at}] } ; AUCUN état lu/non lu
|
||||||
|
* n'existe dans Kanbante, donc tout y est historique et rien n'y
|
||||||
|
* est compté comme actif ; lien = base + "/board/" + repo.
|
||||||
|
* - ObsiGate : AUCUNE notification entrante consultable. Ses
|
||||||
|
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
|
||||||
|
* masqués), pas un journal lisible par l'utilisateur. La section
|
||||||
|
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
|
||||||
|
*
|
||||||
|
* Résolution des bases (même ordre que barre-apps v0.1.6) :
|
||||||
|
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
|
||||||
|
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
|
||||||
|
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
|
||||||
|
* 3. href rendu de l'onglet .draco-apptab[data-app] ;
|
||||||
|
* 4. défaut officiel (production).
|
||||||
|
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
|
||||||
|
* lit aussi window.DRACO_URLS (injecté par son backend).
|
||||||
|
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
|
||||||
|
* que sur un hôte -dev, exactement comme la barre.
|
||||||
|
*
|
||||||
|
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
|
||||||
|
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
|
||||||
|
* pour la pastille (repli : mise à jour directe du badge).
|
||||||
|
* Insertion des données : textContent uniquement, jamais innerHTML
|
||||||
|
* avec une valeur venue du serveur.
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var DEFAULT_URLS = {
|
||||||
|
obsigate: "https://og.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante.dracodev.net/"
|
||||||
|
};
|
||||||
|
var DEV_URLS = {
|
||||||
|
obsigate: "https://og-dev.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante-dev.dracodev.net/"
|
||||||
|
};
|
||||||
|
var APP_LABELS = {
|
||||||
|
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||||
|
tiktak: "TikTak", kanbante: "Kanbante"
|
||||||
|
};
|
||||||
|
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
|
||||||
|
var EVENT_LABELS = {
|
||||||
|
created: "créée", moved: "déplacée", closed: "fermée",
|
||||||
|
reopened: "rouverte", commented: "commentée"
|
||||||
|
};
|
||||||
|
|
||||||
|
function isDevHost(hostname) {
|
||||||
|
if (!hostname) return false;
|
||||||
|
var h = String(hostname).toLowerCase();
|
||||||
|
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||||
|
var first = h.split(".")[0];
|
||||||
|
return first.length > 4 && first.slice(-4) === "-dev";
|
||||||
|
}
|
||||||
|
function cleanUrl(u) {
|
||||||
|
if (!u || typeof u !== "string") return null;
|
||||||
|
if (u.indexOf("{{") !== -1) return null;
|
||||||
|
if (!u.replace(/\s/g, "")) return null;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
function withSlash(u) {
|
||||||
|
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
|
||||||
|
}
|
||||||
|
function joinUrl(base, path) {
|
||||||
|
var b = withSlash(base);
|
||||||
|
if (!path) return b;
|
||||||
|
if (/^https?:\/\//.test(path)) return path;
|
||||||
|
return b + String(path).replace(/^\//, "");
|
||||||
|
}
|
||||||
|
function parseDate(s) {
|
||||||
|
if (!s) return null;
|
||||||
|
var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
|
||||||
|
? String(s).replace(" ", "T") + "Z" : s);
|
||||||
|
return isNaN(t.getTime()) ? null : t;
|
||||||
|
}
|
||||||
|
function fmtDate(s) {
|
||||||
|
var t = parseDate(s);
|
||||||
|
if (!t) return s ? String(s).slice(0, 16) : "";
|
||||||
|
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Résolution des bases. `env` permet les tests sans navigateur :
|
||||||
|
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */
|
||||||
|
function resolveUrls(env) {
|
||||||
|
env = env || {};
|
||||||
|
var hostname = env.hostname;
|
||||||
|
if (hostname === undefined) {
|
||||||
|
try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
|
||||||
|
}
|
||||||
|
var dev = isDevHost(hostname);
|
||||||
|
var urls = {};
|
||||||
|
APP_ORDER.forEach(function (app) {
|
||||||
|
var u = cleanUrl(env.barUrls && env.barUrls[app])
|
||||||
|
|| cleanUrl(env.configUrls && env.configUrls[app]);
|
||||||
|
if (!u && dev) u = DEV_URLS[app] || null;
|
||||||
|
if (!u) {
|
||||||
|
var href = null;
|
||||||
|
if (env.tabHrefs && env.tabHrefs[app]) href = env.tabHrefs[app];
|
||||||
|
else if (typeof document !== "undefined" && document.querySelector) {
|
||||||
|
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||||
|
href = tab ? tab.getAttribute("href") : null;
|
||||||
|
}
|
||||||
|
u = cleanUrl(href);
|
||||||
|
}
|
||||||
|
if (!u && env.dracoUrls && env.dracoUrls[app]) u = cleanUrl(env.dracoUrls[app]);
|
||||||
|
if (!u) {
|
||||||
|
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
|
||||||
|
try {
|
||||||
|
if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]);
|
||||||
|
} catch (e) { /* rien */ }
|
||||||
|
}
|
||||||
|
if (!u) u = DEFAULT_URLS[app];
|
||||||
|
urls[app] = withSlash(u);
|
||||||
|
});
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
function browserEnv() {
|
||||||
|
var cfg = global.DRACO_BAR_CONFIG || {};
|
||||||
|
var bar = global.DRACO_BAR || {};
|
||||||
|
var tabs = {};
|
||||||
|
try {
|
||||||
|
if (typeof document !== "undefined" && document.querySelectorAll) {
|
||||||
|
var nodes = document.querySelectorAll(".draco-apptab[data-app]");
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* rien */ }
|
||||||
|
return {
|
||||||
|
barUrls: bar.urls || null, configUrls: cfg.urls || null,
|
||||||
|
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFlowdeck(base, payload) {
|
||||||
|
var list = (payload && payload.notifications) || [];
|
||||||
|
return list.map(function (n) {
|
||||||
|
return {
|
||||||
|
app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
|
||||||
|
detail: n.message || "", date: n.created_at || "",
|
||||||
|
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||||
|
kind: n.ntype || "", url: joinUrl(base, n.url || "")
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function normalizeTiktak(base, payload) {
|
||||||
|
var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
|
||||||
|
return list.map(function (n) {
|
||||||
|
return {
|
||||||
|
app: "tiktak", id: n.id, title: n.title || "(sans titre)",
|
||||||
|
detail: n.body || "", date: n.created_at || "",
|
||||||
|
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||||
|
kind: n.kind || "",
|
||||||
|
url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function normalizeKanbante(base, payload) {
|
||||||
|
var list = (payload && payload.events) || [];
|
||||||
|
return list.map(function (e) {
|
||||||
|
var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
|
||||||
|
var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
|
||||||
|
return {
|
||||||
|
app: "kanbante", id: e.id,
|
||||||
|
title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
|
||||||
|
detail: detail, date: e.created_at || "",
|
||||||
|
active: false, state: label, kind: e.event_type || "",
|
||||||
|
url: joinUrl(base, "board/" + (e.repo_full_name || ""))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/* Actives d'abord, puis date décroissante (sans date en dernier). */
|
||||||
|
function sortAlerts(list) {
|
||||||
|
return list.slice().sort(function (a, b) {
|
||||||
|
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||||
|
var da = parseDate(a.date), db = parseDate(b.date);
|
||||||
|
if (da && db) return db - da;
|
||||||
|
if (da) return -1;
|
||||||
|
if (db) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function countActive(list) {
|
||||||
|
var n = 0;
|
||||||
|
list.forEach(function (a) { if (a.active) n++; });
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Rendu (navigateur seulement) ---- */
|
||||||
|
var panel = null, listEl = null, badgeTimer = null;
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var d = document.createElement(tag);
|
||||||
|
if (cls) d.className = cls;
|
||||||
|
if (text !== undefined && text !== null) d.textContent = text;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function ensurePanel() {
|
||||||
|
if (panel) return panel;
|
||||||
|
var ov = el("div", "draco-alertes-overlay");
|
||||||
|
ov.setAttribute("role", "dialog");
|
||||||
|
ov.setAttribute("aria-label", "Alertes unifiées");
|
||||||
|
var box = el("div", "draco-alertes-box");
|
||||||
|
var head = el("div", "draco-alertes-head");
|
||||||
|
head.appendChild(el("h2", "", "Alertes unifiées"));
|
||||||
|
var note = el("p", "draco-alertes-note",
|
||||||
|
"Agrégation à la demande, pas une fédération temps réel : " +
|
||||||
|
"les alertes sont relues dans chaque application à l'ouverture.");
|
||||||
|
var close = el("button", "draco-alertes-close", "✕");
|
||||||
|
close.setAttribute("aria-label", "Fermer");
|
||||||
|
close.addEventListener("click", closePanel);
|
||||||
|
head.appendChild(close);
|
||||||
|
box.appendChild(head);
|
||||||
|
box.appendChild(note);
|
||||||
|
listEl = el("div", "draco-alertes-list");
|
||||||
|
box.appendChild(listEl);
|
||||||
|
ov.appendChild(box);
|
||||||
|
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
|
||||||
|
document.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key === "Escape") closePanel();
|
||||||
|
});
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
panel = ov;
|
||||||
|
return ov;
|
||||||
|
}
|
||||||
|
function closePanel() { if (panel) panel.style.display = "none"; }
|
||||||
|
|
||||||
|
function renderSection(title, items, fallback) {
|
||||||
|
var sec = el("section", "draco-alertes-section");
|
||||||
|
sec.appendChild(el("h3", "", title + " (" + items.length + ")"));
|
||||||
|
if (!items.length) {
|
||||||
|
sec.appendChild(el("p", "draco-alertes-empty", fallback));
|
||||||
|
return sec;
|
||||||
|
}
|
||||||
|
items.forEach(function (a) {
|
||||||
|
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
|
||||||
|
var main = el("div", "draco-alertes-main");
|
||||||
|
var link = el("a", "draco-alertes-title", a.title);
|
||||||
|
link.href = a.url;
|
||||||
|
main.appendChild(link);
|
||||||
|
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
|
||||||
|
var meta = el("div", "draco-alertes-meta",
|
||||||
|
APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") +
|
||||||
|
" · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state);
|
||||||
|
main.appendChild(meta);
|
||||||
|
row.appendChild(main);
|
||||||
|
sec.appendChild(row);
|
||||||
|
});
|
||||||
|
return sec;
|
||||||
|
}
|
||||||
|
function render(results, urls) {
|
||||||
|
ensurePanel();
|
||||||
|
listEl.textContent = "";
|
||||||
|
var all = [];
|
||||||
|
APP_ORDER.forEach(function (app) {
|
||||||
|
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
|
||||||
|
});
|
||||||
|
var actives = sortAlerts(all.filter(function (a) { return a.active; }));
|
||||||
|
var history = sortAlerts(all.filter(function (a) { return !a.active; }));
|
||||||
|
listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives"));
|
||||||
|
APP_ORDER.forEach(function (app) {
|
||||||
|
var items = actives.filter(function (a) { return a.app === app; });
|
||||||
|
if (results[app] && results[app].error) {
|
||||||
|
var sec = renderSection(APP_LABELS[app], [], results[app].error);
|
||||||
|
listEl.appendChild(sec);
|
||||||
|
} else if (app === "obsigate") {
|
||||||
|
listEl.appendChild(renderSection(APP_LABELS[app], [],
|
||||||
|
"ObsiGate n'expose aucune notification entrante consultable " +
|
||||||
|
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
|
||||||
|
} else if (items.length) {
|
||||||
|
listEl.appendChild(renderSection(APP_LABELS[app], items, ""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!actives.length) {
|
||||||
|
listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables."));
|
||||||
|
}
|
||||||
|
listEl.appendChild(el("h3", "draco-alertes-group", "Historique"));
|
||||||
|
APP_ORDER.forEach(function (app) {
|
||||||
|
if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */
|
||||||
|
if (app === "obsigate") {
|
||||||
|
var link = el("div", "draco-alertes-empty");
|
||||||
|
var a = el("a", "", "Ouvrir ObsiGate");
|
||||||
|
a.href = urls.obsigate;
|
||||||
|
link.appendChild(a);
|
||||||
|
var sec = el("section", "draco-alertes-section");
|
||||||
|
sec.appendChild(el("h3", "", "ObsiGate (0)"));
|
||||||
|
sec.appendChild(link);
|
||||||
|
listEl.appendChild(sec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var items = history.filter(function (x) { return x.app === app; });
|
||||||
|
listEl.appendChild(renderSection(APP_LABELS[app], items,
|
||||||
|
"Aucun historique disponible pour cette application."));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchJson(url, timeoutMs) {
|
||||||
|
var ctrl = null, timer = null;
|
||||||
|
try {
|
||||||
|
if (typeof AbortController !== "undefined") {
|
||||||
|
ctrl = new AbortController();
|
||||||
|
timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
|
||||||
|
}
|
||||||
|
} catch (e) { ctrl = null; }
|
||||||
|
var opts = { credentials: "include" };
|
||||||
|
if (ctrl) opts.signal = ctrl.signal;
|
||||||
|
return fetch(url, opts).then(function (r) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (!r.ok) {
|
||||||
|
var err = new Error("HTTP " + r.status);
|
||||||
|
err.status = r.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
}).catch(function (e) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadAll() {
|
||||||
|
var urls = resolveUrls(browserEnv());
|
||||||
|
var jobs = {
|
||||||
|
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
|
||||||
|
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
|
||||||
|
tiktak: fetchJson(urls.tiktak + "api/notifications")
|
||||||
|
.then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
|
||||||
|
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
|
||||||
|
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
|
||||||
|
};
|
||||||
|
var keys = Object.keys(jobs);
|
||||||
|
return Promise.all(keys.map(function (k) {
|
||||||
|
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
|
||||||
|
var msg = (e && e.status === 401)
|
||||||
|
? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
|
||||||
|
: "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
|
||||||
|
return [k, { items: [], error: msg }];
|
||||||
|
});
|
||||||
|
})).then(function (pairs) {
|
||||||
|
var results = { obsigate: { items: [] } };
|
||||||
|
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
|
||||||
|
return { urls: urls, results: results };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setBadge(n) {
|
||||||
|
try {
|
||||||
|
if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
|
||||||
|
global.DRACO_BAR.setCounts({ alertes: n });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) { /* repli ci-dessous */ }
|
||||||
|
try {
|
||||||
|
document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
|
||||||
|
b.textContent = n;
|
||||||
|
b.hidden = !(n > 0);
|
||||||
|
});
|
||||||
|
} catch (e) { /* rien */ }
|
||||||
|
}
|
||||||
|
function refreshBadge() {
|
||||||
|
return loadAll().then(function (r) {
|
||||||
|
var all = [];
|
||||||
|
APP_ORDER.forEach(function (app) {
|
||||||
|
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
|
||||||
|
});
|
||||||
|
setBadge(countActive(all));
|
||||||
|
return countActive(all);
|
||||||
|
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
|
||||||
|
}
|
||||||
|
function openPanel() {
|
||||||
|
ensurePanel();
|
||||||
|
panel.style.display = "flex";
|
||||||
|
listEl.textContent = "";
|
||||||
|
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
|
||||||
|
loadAll().then(function (r) {
|
||||||
|
render(r.results, r.urls);
|
||||||
|
var all = [];
|
||||||
|
APP_ORDER.forEach(function (app) {
|
||||||
|
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
|
||||||
|
});
|
||||||
|
setBadge(countActive(all));
|
||||||
|
}).catch(function () {
|
||||||
|
render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var api = {
|
||||||
|
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
|
||||||
|
_test: {
|
||||||
|
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
|
||||||
|
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
|
||||||
|
sortAlerts: sortAlerts, countActive: countActive,
|
||||||
|
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS
|
||||||
|
}
|
||||||
|
};
|
||||||
|
global.DRACO_ALERTES = api;
|
||||||
|
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
|
||||||
|
|
||||||
|
/* Pastille au chargement (après les scripts defer de la barre). */
|
||||||
|
if (typeof document !== "undefined" && document.addEventListener) {
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
if (badgeTimer) clearTimeout(badgeTimer);
|
||||||
|
badgeTimer = setTimeout(refreshBadge, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
Reference in New Issue
Block a user