nodebb-plugin-ezoic-infinite 1.7.15 → 1.7.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/library.js CHANGED
@@ -9,8 +9,10 @@ const plugin = {};
9
9
 
10
10
  function normalizeExcludedGroups(value) {
11
11
  if (!value) return [];
12
- if (Array.isArray(value)) return value;
13
- return String(value).split(',').map(s => s.trim()).filter(Boolean);
12
+ const arr = Array.isArray(value) ? value : String(value).split(',');
13
+ return arr
14
+ .map(s => String(s).trim())
15
+ .filter(Boolean);
14
16
  }
15
17
 
16
18
  function parseBool(v, def = false) {
@@ -20,18 +22,30 @@ function parseBool(v, def = false) {
20
22
  return s === '1' || s === 'true' || s === 'on' || s === 'yes';
21
23
  }
22
24
 
25
+ let _groupsCache = null;
26
+ let _groupsCacheAt = 0;
27
+ const GROUPS_TTL = 60000; // 60s
28
+
23
29
  async function getAllGroups() {
30
+ const now = Date.now();
31
+ if (_groupsCache && (now - _groupsCacheAt) < GROUPS_TTL) return _groupsCache;
32
+
24
33
  let names = await db.getSortedSetRange('groups:createtime', 0, -1);
25
34
  if (!names || !names.length) {
26
35
  names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
27
36
  }
28
37
  const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
29
38
  const data = await groups.getGroupsData(filtered);
39
+
30
40
  // Filter out nulls (groups deleted between the sorted-set read and getGroupsData)
31
- const valid = data.filter(g => g && g.name);
41
+ const valid = (data || []).filter(g => g && g.name);
32
42
  valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
33
- return valid;
43
+
44
+ _groupsCache = valid;
45
+ _groupsCacheAt = now;
46
+ return _groupsCache;
34
47
  }
48
+
35
49
  let _settingsCache = null;
36
50
  let _settingsCacheAt = 0;
37
51
  const SETTINGS_TTL = 30000; // 30s
@@ -39,8 +53,13 @@ const SETTINGS_TTL = 30000; // 30s
39
53
  async function getSettings() {
40
54
  const now = Date.now();
41
55
  if (_settingsCache && (now - _settingsCacheAt) < SETTINGS_TTL) return _settingsCache;
42
- const s = await meta.settings.get(SETTINGS_KEY);
43
- _settingsCacheAt = Date.now();
56
+ let s = {};
57
+ try {
58
+ s = await meta.settings.get(SETTINGS_KEY);
59
+ } catch (err) {
60
+ s = {};
61
+ }
62
+ _settingsCacheAt = now;
44
63
  _settingsCache = {
45
64
  // Between-post ads (simple blocks) in category topic list
46
65
  enableBetweenAds: parseBool(s.enableBetweenAds, true),
@@ -60,6 +79,9 @@ async function getSettings() {
60
79
  messagePlaceholderIds: (s.messagePlaceholderIds || '').trim(),
61
80
  messageIntervalPosts: Math.max(1, parseInt(s.messageIntervalPosts, 10) || 3),
62
81
 
82
+ // Avoid globally muting console unless explicitly enabled
83
+ muteEzoicConsole: parseBool(s.muteEzoicConsole, false),
84
+
63
85
  excludedGroups: normalizeExcludedGroups(s.excludedGroups),
64
86
  };
65
87
  return _settingsCache;
@@ -68,7 +90,8 @@ async function getSettings() {
68
90
  async function isUserExcluded(uid, excludedGroups) {
69
91
  if (!uid || !excludedGroups.length) return false;
70
92
  const userGroups = await groups.getUserGroups([uid]);
71
- return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
93
+ const excluded = new Set(excludedGroups.map(n => String(n).toLowerCase().trim()).filter(Boolean));
94
+ return (userGroups[0] || []).some(g => excluded.has(String(g.name).toLowerCase().trim()));
72
95
  }
73
96
 
74
97
  plugin.onSettingsSet = function (data) {
@@ -93,21 +116,53 @@ plugin.init = async ({ router, middleware }) => {
93
116
  const settings = await getSettings();
94
117
  const allGroups = await getAllGroups();
95
118
 
119
+ const excludedSet = new Set((settings.excludedGroups || []).map(n => String(n).toLowerCase().trim()).filter(Boolean));
120
+ const allGroupsWithSelected = (allGroups || []).map(g => ({
121
+ ...g,
122
+ selected: excludedSet.has(String(g.name).toLowerCase().trim()) ? 'selected' : '',
123
+ }));
124
+
96
125
  res.render('admin/plugins/ezoic-infinite', {
97
126
  title: 'Ezoic Infinite Ads',
98
127
  ...settings,
128
+
129
+ // SSR-friendly checkbox states
99
130
  enableBetweenAds_checked: settings.enableBetweenAds ? 'checked' : '',
131
+ showFirstTopicAd_checked: settings.showFirstTopicAd ? 'checked' : '',
132
+
133
+ enableCategoryAds_checked: settings.enableCategoryAds ? 'checked' : '',
134
+ showFirstCategoryAd_checked: settings.showFirstCategoryAd ? 'checked' : '',
135
+
100
136
  enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
101
- allGroups,
137
+ showFirstMessageAd_checked: settings.showFirstMessageAd ? 'checked' : '',
138
+
139
+ muteEzoicConsole_checked: settings.muteEzoicConsole ? 'checked' : '',
140
+
141
+ allGroups: allGroupsWithSelected,
102
142
  });
103
143
  }
104
144
 
105
- router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
106
- router.get('/api/admin/plugins/ezoic-infinite', render);
145
+ router.get('/admin/plugins/ezoic-infinite', middleware.ensureLoggedIn, middleware.admin.checkPrivileges, middleware.admin.buildHeader, render);
146
+ router.get('/api/admin/plugins/ezoic-infinite', middleware.ensureLoggedIn, middleware.admin.checkPrivileges, render);
107
147
 
108
148
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
109
- const settings = await getSettings();
110
- const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
149
+ let settings;
150
+ try {
151
+ settings = await getSettings();
152
+ } catch (err) {
153
+ settings = null;
154
+ }
155
+
156
+ if (!settings) {
157
+ return res.json({ excluded: false });
158
+ }
159
+
160
+ let excluded = false;
161
+ try {
162
+ excluded = await isUserExcluded(req.uid, settings.excludedGroups);
163
+ } catch (err) {
164
+ excluded = false;
165
+ }
111
166
 
112
167
  res.json({
113
168
  excluded,
@@ -123,6 +178,7 @@ plugin.init = async ({ router, middleware }) => {
123
178
  showFirstMessageAd: settings.showFirstMessageAd,
124
179
  messagePlaceholderIds: settings.messagePlaceholderIds,
125
180
  messageIntervalPosts: settings.messageIntervalPosts,
181
+ muteEzoicConsole: settings.muteEzoicConsole,
126
182
  });
127
183
  });
128
184
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.15",
3
+ "version": "1.7.17",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -12,10 +12,19 @@
12
12
  "infinite-scroll"
13
13
  ],
14
14
  "engines": {
15
- "nodebb": ">=4.0.0"
15
+ "nodebb": ">=4.0.0",
16
+ "node": ">=18"
16
17
  },
17
18
  "nbbpm": {
18
19
  "compatibility": "^4.0.0"
19
20
  },
20
- "private": false
21
- }
21
+ "private": false,
22
+ "files": [
23
+ "library.js",
24
+ "plugin.json",
25
+ "public/"
26
+ ],
27
+ "scripts": {
28
+ "lint": "node -c library.js && node -c public/admin.js && node -c public/client.js"
29
+ }
30
+ }
package/public/client.js CHANGED
@@ -1,49 +1,55 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v21
2
+ * NodeBB Ezoic Infinite Ads — client.js (v20)
3
3
  *
4
- * Historique des corrections majeures
5
- * ────────────────────────────────────
6
- * v18 Ancrage stable par data-pid/data-index au lieu d'ordinalMap fragile.
7
- * Suppression du recyclage de wraps (moveWrapAfter). Cleanup complet navigation.
4
+ * Correctifs critiques vs v19
5
+ * ───────────────────────────
6
+ * [BUG FATAL] Pubs catégories disparaissent au premier scroll sur mobile
7
+ * pruneOrphans cherchait l'ancre via `[data-index]`, mais les éléments
8
+ * `li[component="categories/category"]` ont `data-cid`, pas `data-index`.
9
+ * → anchorEl toujours null → suppression à chaque runCore() → disparition.
10
+ * Fix : table KIND_META qui mappe chaque kindClass vers son attribut d'ancre
11
+ * stable (data-pid pour posts, data-index pour topics, data-cid pour catégories).
8
12
  *
9
- * v19 Intervalle global basé sur l'ordinal absolu (data-index) et non sur
10
- * la position dans le batch courant.
13
+ * [BUG] decluster : break trop large stoppait tout quand UN wrap était en grâce
14
+ * Fix : on skip uniquement le wrap courant, pas toute la boucle.
11
15
  *
12
- * v20 Table KIND : anchorAttr/ordinalAttr/baseTag explicites par kindClass.
13
- * Fix fatal catégories : data-cid au lieu de data-index inexistant.
14
- * Fix posts : baseTag vide → ordinal fallback cassé → pubs jamais injectées.
15
- * IO fixe (une instance, jamais recréée). Burst cooldown 200 ms.
16
- * Fix unobserve(null) → corruption IO → pubads error au scroll retour.
17
- * Fix TCF locator : MutationObserver recrée l'iframe si ajaxify la retire.
16
+ * [BUG] injectBetween : `break` sur pool épuisé empêchait d'observer les wraps
17
+ * existants sur les items suivants. Fix : `continue` au lieu de `break`.
18
18
  *
19
- * v21 Suppression de toute la logique wyvern.js (pause/destroy avant remove) :
20
- * les erreurs wyvern viennent du SDK Ezoic lui-même lors de ses propres
21
- * refreshes internes, pas de nos suppressions. Nos wraps filled ne sont
22
- * de toute façon jamais supprimés (règle pruneOrphans/decluster).
23
- * Refactorisation finale prod-ready : code unifié, zéro duplication,
24
- * commentaires essentiels uniquement.
19
+ * [PERF] IntersectionObserver recréé à chaque scroll boost très coûteux mobile.
20
+ * Fix : marge large fixe par device, observer créé une seule fois.
21
+ *
22
+ * [PERF] Burst cooldown 100ms trop court sur mobile → rafales en cascade.
23
+ * Fix : 200ms.
24
+ *
25
+ * Nettoyage
26
+ * ─────────
27
+ * - Suppression du scroll boost (complexité sans gain mesurable côté SPA statique)
28
+ * - MAX_INFLIGHT unique desktop/mobile (inutile de différencier)
29
+ * - getAnchorKey/getGlobalOrdinal fusionnés en helpers cohérents
30
+ * - Commentaires internes allégés (code auto-documenté)
25
31
  */
26
32
  (function () {
27
33
  'use strict';
28
34
 
29
35
  // ── Constantes ─────────────────────────────────────────────────────────────
30
36
 
31
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
32
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
33
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
34
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
35
- const A_CREATED = 'data-ezoic-created'; // timestamp création ms
36
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
37
-
38
- const MIN_PRUNE_AGE_MS = 8_000; // garde-fou post-batch NodeBB
39
- const FILL_GRACE_MS = 25_000; // fenêtre fill async Ezoic (SSP auction)
40
- const EMPTY_CHECK_MS = 20_000; // délai collapse wrap vide post-show
41
- const MAX_INSERTS_RUN = 6;
42
- const MAX_INFLIGHT = 4;
43
- const SHOW_THROTTLE_MS = 900;
44
- const BURST_COOLDOWN_MS = 200;
45
-
46
- // IO : marges larges fixes une seule instance, jamais recréée
37
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
38
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
39
+ const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
40
+ const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic (number string)
41
+ const A_CREATED = 'data-ezoic-created'; // timestamp création (ms)
42
+ const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds (ms)
43
+
44
+ const MIN_PRUNE_AGE_MS = 8_000; // délai avant qu'un wrap puisse être purgé
45
+ const FILL_GRACE_MS = 25_000; // fenêtre post-showAds l'on ne decluster pas
46
+ const EMPTY_CHECK_MS = 20_000; // délai avant de marquer un wrap vide
47
+ const MAX_INSERTS_PER_RUN = 6;
48
+ const MAX_INFLIGHT = 4;
49
+ const SHOW_THROTTLE_MS = 900;
50
+ const BURST_COOLDOWN_MS = 200;
51
+
52
+ // Marges IO larges et fixes (pas de reconstruction d'observer)
47
53
  const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
48
54
  const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
49
55
 
@@ -54,40 +60,41 @@
54
60
  };
55
61
 
56
62
  /**
57
- * Table KIND source de vérité par kindClass.
63
+ * Table centrale : kindClass { selector DOM, attribut d'ancre stable }
58
64
  *
59
- * sel : sélecteur CSS complet
60
- * baseTag : préfixe tag pour les querySelector de recherche d'ancre
61
- * (vide pour posts car leur sélecteur commence par '[')
62
- * anchorAttr : attribut DOM STABLE clé unique du wrap, permanent
63
- * data-pid posts (id message, immuable)
64
- * data-index topics (index dans la liste)
65
- * data-cid catégories (id catégorie, immuable)
66
- * ordinalAttr: attribut 0-based pour le calcul de l'intervalle
67
- * data-index posts + topics (fourni par NodeBB)
68
- * null catégories (page statique → fallback positionnel)
65
+ * L'attribut d'ancre est l'identifiant que NodeBB pose TOUJOURS sur l'élément,
66
+ * quelle que soit la page ou la virtualisation :
67
+ * posts data-pid (id du message, unique et permanent)
68
+ * topics → data-index (position 0-based dans la liste, fourni par NodeBB)
69
+ * catégories → data-cid (id de la catégorie, unique et permanent)
70
+ * ← C'était le bug v19 : on cherchait data-index ici
69
71
  */
70
72
  const KIND = {
71
- 'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
72
- 'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
73
- 'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
73
+ 'ezoic-ad-message': { sel: SEL.post, anchorAttr: 'data-pid' },
74
+ 'ezoic-ad-between': { sel: SEL.topic, anchorAttr: 'data-index' },
75
+ 'ezoic-ad-categories': { sel: SEL.category, anchorAttr: 'data-cid' },
74
76
  };
75
77
 
76
78
  // ── État ───────────────────────────────────────────────────────────────────
77
79
 
78
80
  const S = {
79
- pageKey: null,
80
- cfg: null,
81
- pools: { topics: [], posts: [], categories: [] },
82
- cursors: { topics: 0, posts: 0, categories: 0 },
83
- mountedIds: new Set(), // IDs Ezoic montés dans le DOM
84
- lastShow: new Map(), // id timestamp dernier show
85
- io: null,
86
- domObs: null,
87
- mutGuard: 0,
88
- inflight: 0,
89
- pending: [],
90
- pendingSet: new Set(),
81
+ pageKey: null,
82
+ cfg: null,
83
+
84
+ pools: { topics: [], posts: [], categories: [] },
85
+ cursors: { topics: 0, posts: 0, categories: 0 },
86
+ mountedIds: new Set(), // IDs Ezoic actuellement dans le DOM
87
+ reuseSeq: new Map(), // id → compteur pour générer des IDs DOM uniques
88
+ lastShow: new Map(), // id → timestamp dernier show
89
+
90
+ io: null,
91
+ domObs: null,
92
+ mutGuard: 0, // compteur internalMutation
93
+
94
+ inflight: 0,
95
+ pending: [],
96
+ pendingSet: new Set(),
97
+
91
98
  runQueued: false,
92
99
  burstActive: false,
93
100
  burstDeadline: 0,
@@ -96,11 +103,8 @@
96
103
  };
97
104
 
98
105
  let blockedUntil = 0;
99
- const ts = () => Date.now();
100
- const isBlocked = () => ts() < blockedUntil;
101
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
102
- const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
103
- const isFilled = (n) => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
106
+ const isBlocked = () => Date.now() < blockedUntil;
107
+ const ts = () => Date.now();
104
108
 
105
109
  function mutate(fn) {
106
110
  S.mutGuard++;
@@ -118,20 +122,30 @@
118
122
  return S.cfg;
119
123
  }
120
124
 
125
+ function initPools(cfg) {
126
+ S.pools.topics = parseIds(cfg.placeholderIds);
127
+ S.pools.posts = parseIds(cfg.messagePlaceholderIds);
128
+ S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
129
+ }
130
+
121
131
  function parseIds(raw) {
122
132
  const out = [], seen = new Set();
123
- for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
133
+ for (const v of String(raw || '').split(/[\s,]+/).map(s => s.trim()).filter(Boolean)) {
124
134
  const n = parseInt(v, 10);
125
135
  if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
126
136
  }
127
137
  return out;
128
138
  }
129
139
 
130
- function initPools(cfg) {
131
- S.pools.topics = parseIds(cfg.placeholderIds);
132
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
133
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
134
- }
140
+ // La réutilisation des IDs du pool est toujours autorisée pour éviter les
141
+ // situations "pool épuisé". Les IDs HTML des placeholders sont rendus uniques
142
+ // (suffixés) afin de ne pas dupliquer d'ID dans le DOM.
143
+ const allowReuse = () => true;
144
+
145
+ const isFilled = (n) =>
146
+ !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
147
+
148
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
135
149
 
136
150
  // ── Page identity ──────────────────────────────────────────────────────────
137
151
 
@@ -155,13 +169,13 @@
155
169
  return 'other';
156
170
  }
157
171
 
158
- // ── Items DOM ──────────────────────────────────────────────────────────────
172
+ // ── DOM helpers ────────────────────────────────────────────────────────────
159
173
 
160
174
  function getPosts() {
161
175
  return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
162
176
  if (!el.isConnected) return false;
163
177
  if (!el.querySelector('[component="post/content"]')) return false;
164
- const p = el.parentElement?.closest(SEL.post);
178
+ const p = el.parentElement?.closest('[component="post"][data-pid]');
165
179
  if (p && p !== el) return false;
166
180
  return el.getAttribute('component') !== 'post/parent';
167
181
  });
@@ -177,28 +191,36 @@
177
191
  );
178
192
  }
179
193
 
180
- // ── Ancres stables ─────────────────────────────────────────────────────────
194
+ // ── Ancres stables ────────────────────────────────────────────────────────
181
195
 
182
- function stableId(klass, el) {
183
- const attr = KIND[klass]?.anchorAttr;
196
+ /**
197
+ * Retourne l'identifiant stable de l'élément selon son kindClass.
198
+ * Utilise l'attribut défini dans KIND (data-pid, data-index, data-cid).
199
+ * Fallback positionnel si l'attribut est absent.
200
+ */
201
+ function stableId(kindClass, el) {
202
+ const attr = KIND[kindClass]?.anchorAttr;
184
203
  if (attr) {
185
204
  const v = el.getAttribute(attr);
186
205
  if (v !== null && v !== '') return v;
187
206
  }
188
- let i = 0;
189
- for (const s of el.parentElement?.children ?? []) {
190
- if (s === el) return `i${i}`;
191
- i++;
192
- }
207
+ // Fallback : position dans le parent
208
+ try {
209
+ let i = 0;
210
+ for (const s of el.parentElement?.children ?? []) {
211
+ if (s === el) return `i${i}`;
212
+ i++;
213
+ }
214
+ } catch (_) {}
193
215
  return 'i0';
194
216
  }
195
217
 
196
- const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
218
+ const makeAnchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
197
219
 
198
- function findWrap(key) {
220
+ function findWrap(anchorKey) {
199
221
  try {
200
222
  return document.querySelector(
201
- `.${WRAP_CLASS}[${A_ANCHOR}="${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
223
+ `.${WRAP_CLASS}[${A_ANCHOR}="${anchorKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
202
224
  );
203
225
  } catch (_) { return null; }
204
226
  }
@@ -208,35 +230,39 @@
208
230
  function pickId(poolKey) {
209
231
  const pool = S.pools[poolKey];
210
232
  for (let t = 0; t < pool.length; t++) {
211
- const i = S.cursors[poolKey] % pool.length;
233
+ const i = S.cursors[poolKey] % pool.length;
212
234
  S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
213
235
  const id = pool[i];
214
- if (!S.mountedIds.has(id)) return id;
236
+ return id;
215
237
  }
216
238
  return null;
217
239
  }
218
240
 
219
241
  // ── Wraps DOM ──────────────────────────────────────────────────────────────
220
242
 
243
+ function nextDomPlaceholderId(id) {
244
+ const cur = (S.reuseSeq.get(id) || 0) + 1;
245
+ S.reuseSeq.set(id, cur);
246
+ return `${PH_PREFIX}${id}-${cur}`;
247
+ }
248
+
221
249
  function makeWrap(id, klass, key) {
222
- const w = document.createElement('div');
250
+ const w = document.createElement('div');
223
251
  w.className = `${WRAP_CLASS} ${klass}`;
224
252
  w.setAttribute(A_ANCHOR, key);
225
253
  w.setAttribute(A_WRAPID, String(id));
226
254
  w.setAttribute(A_CREATED, String(ts()));
227
255
  w.style.cssText = 'width:100%;display:block;';
228
256
  const ph = document.createElement('div');
229
- ph.id = `${PH_PREFIX}${id}`;
257
+ ph.id = nextDomPlaceholderId(id);
230
258
  ph.setAttribute('data-ezoic-id', String(id));
231
259
  w.appendChild(ph);
232
260
  return w;
233
261
  }
234
262
 
235
263
  function insertAfter(el, id, klass, key) {
236
- if (!el?.insertAdjacentElement) return null;
237
- if (findWrap(key)) return null;
238
- if (S.mountedIds.has(id)) return null;
239
- if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
264
+ if (!el?.insertAdjacentElement) return null;
265
+ if (findWrap(key)) return null; // ancre déjà présente
240
266
  const w = makeWrap(id, klass, key);
241
267
  mutate(() => el.insertAdjacentElement('afterend', w));
242
268
  S.mountedIds.add(id);
@@ -245,12 +271,15 @@
245
271
 
246
272
  function dropWrap(w) {
247
273
  try {
248
- // Unobserve avant remove — guard instanceof évite unobserve(null)
249
- // qui corrompt l'état interne de l'IO (pubads error au scroll suivant)
250
- const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
251
- if (ph instanceof Element) S.io?.unobserve(ph);
252
274
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
253
275
  if (Number.isFinite(id)) S.mountedIds.delete(id);
276
+ // IMPORTANT : ne passer unobserve que si c'est un vrai Element.
277
+ // unobserve(null) corrompt l'état interne de l'IO (pubads lève ensuite
278
+ // "parameter 1 is not of type Element" sur le prochain observe).
279
+ try {
280
+ const ph = w.querySelector('[data-ezoic-id]') || w.querySelector(`[id^="${PH_PREFIX}"]`);
281
+ if (ph instanceof Element) S.io?.unobserve(ph);
282
+ } catch (_) {}
254
283
  w.remove();
255
284
  } catch (_) {}
256
285
  }
@@ -258,42 +287,46 @@
258
287
  // ── Prune ──────────────────────────────────────────────────────────────────
259
288
 
260
289
  /**
261
- * Supprime les wraps VIDES dont l'ancre a disparu du DOM.
290
+ * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
291
+ *
292
+ * L'ancre est retrouvée via l'attribut stable défini dans KIND[kindClass].anchorAttr.
293
+ * Exemples :
294
+ * ezoic-ad-message → cherche [data-pid="123"]
295
+ * ezoic-ad-between → cherche [data-index="5"]
296
+ * ezoic-ad-categories → cherche [data-cid="7"] ← fix v20
262
297
  *
263
- * On ne supprime JAMAIS un wrap rempli (filled) :
264
- * - Les wraps remplis peuvent être temporairement orphelins lors d'une
265
- * virtualisation NodeBB — l'ancre reviendra.
266
- * - Le SDK Ezoic (wyvern, GAM) exécute des callbacks async sur le contenu ;
267
- * retirer le nœud sous ses pieds génère des erreurs non critiques mais
268
- * inutiles. Le cleanup de navigation gère la suppression définitive.
298
+ * On ne prune pas les wraps < MIN_PRUNE_AGE_MS (DOM pas encore stabilisé).
269
299
  */
270
300
  function pruneOrphans(klass) {
271
301
  const meta = KIND[klass];
272
302
  if (!meta) return;
273
303
 
274
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
275
- if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) continue;
276
- if (isFilled(w)) continue;
304
+ const baseTag = meta.sel.split('[')[0]; // ex: "li", "[component=..." → "" (géré)
305
+
306
+ document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
307
+ if (ts() - parseInt(w.getAttribute(A_CREATED) || '0', 10) < MIN_PRUNE_AGE_MS) return;
277
308
 
278
309
  const key = w.getAttribute(A_ANCHOR) ?? '';
279
- const sid = key.slice(klass.length + 1);
280
- if (!sid) { mutate(() => dropWrap(w)); continue; }
310
+ const sid = key.slice(klass.length + 1); // extrait la partie après "kindClass:"
311
+ if (!sid) { mutate(() => dropWrap(w)); return; }
281
312
 
282
- const sel = `${meta.baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`;
283
- const anchorEl = document.querySelector(sel);
313
+ const anchorEl = document.querySelector(
314
+ `${baseTag}[${meta.anchorAttr}="${sid.replace(/"/g, '\\"')}"]`
315
+ );
284
316
  if (!anchorEl || !anchorEl.isConnected) mutate(() => dropWrap(w));
285
- }
317
+ });
286
318
  }
287
319
 
288
320
  // ── Decluster ──────────────────────────────────────────────────────────────
289
321
 
290
322
  /**
291
- * Deux wraps adjacents → supprimer le moins prioritaire.
292
- * Priorité : filled > en grâce de fill > vide.
293
- * Jamais de suppression d'un wrap rempli (même raison que pruneOrphans).
323
+ * Deux wraps adjacents = situation anormale → supprimer le moins prioritaire.
324
+ * Priorité : filled > en grâce (fill en cours) > vide.
325
+ * Ne supprime jamais un wrap dont showAds() date de moins de FILL_GRACE_MS.
294
326
  */
295
327
  function decluster(klass) {
296
328
  for (const w of document.querySelectorAll(`.${WRAP_CLASS}.${klass}`)) {
329
+ // Grace sur le wrap courant : on le saute entièrement
297
330
  const wShown = parseInt(w.getAttribute(A_SHOWN) || '0', 10);
298
331
  if (wShown && ts() - wShown < FILL_GRACE_MS) continue;
299
332
 
@@ -302,11 +335,10 @@
302
335
  if (!prev.classList?.contains(WRAP_CLASS)) { prev = prev.previousElementSibling; continue; }
303
336
 
304
337
  const pShown = parseInt(prev.getAttribute(A_SHOWN) || '0', 10);
305
- if (pShown && ts() - pShown < FILL_GRACE_MS) break;
338
+ if (pShown && ts() - pShown < FILL_GRACE_MS) break; // précédent en grâce → rien
306
339
 
307
340
  if (!isFilled(w)) mutate(() => dropWrap(w));
308
341
  else if (!isFilled(prev)) mutate(() => dropWrap(prev));
309
- // les deux remplis → on ne touche pas
310
342
  break;
311
343
  }
312
344
  }
@@ -316,22 +348,23 @@
316
348
 
317
349
  /**
318
350
  * Ordinal 0-based pour le calcul de l'intervalle.
319
- * Posts et topics : data-index (NodeBB 4.x, stable entre les batches).
320
- * Catégories : fallback positionnel (page statique, pas d'infinite scroll).
351
+ * Pour posts/topics : data-index (NodeBB 3+/4+, toujours présent).
352
+ * Pour catégories : position dans le parent (page statique, pas d'infinite scroll).
321
353
  */
322
354
  function ordinal(klass, el) {
323
- const attr = KIND[klass]?.ordinalAttr;
324
- if (attr) {
325
- const v = el.getAttribute(attr);
326
- if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
327
- }
328
- // Fallback positionnel — compte uniquement les éléments du même type
329
- const fullSel = KIND[klass]?.sel ?? '';
330
- let i = 0;
331
- for (const s of el.parentElement?.children ?? []) {
332
- if (s === el) return i;
333
- if (!fullSel || s.matches?.(fullSel)) i++;
334
- }
355
+ const di = el.getAttribute('data-index');
356
+ if (di !== null && di !== '' && !isNaN(di)) return parseInt(di, 10);
357
+ // Fallback positionnel
358
+ try {
359
+ const tag = KIND[klass]?.sel?.split('[')?.[0] ?? '';
360
+ if (tag) {
361
+ let i = 0;
362
+ for (const n of el.parentElement?.querySelectorAll(`:scope > ${tag}`) ?? []) {
363
+ if (n === el) return i;
364
+ i++;
365
+ }
366
+ }
367
+ } catch (_) {}
335
368
  return 0;
336
369
  }
337
370
 
@@ -340,18 +373,20 @@
340
373
  let inserted = 0;
341
374
 
342
375
  for (const el of items) {
343
- if (inserted >= MAX_INSERTS_RUN) break;
376
+ if (inserted >= MAX_INSERTS_PER_RUN) break;
344
377
  if (!el?.isConnected) continue;
345
378
 
346
- const ord = ordinal(klass, el);
347
- if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
379
+ const ord = ordinal(klass, el);
380
+ const isTarget = (showFirst && ord === 0) || ((ord + 1) % interval === 0);
381
+ if (!isTarget) continue;
382
+
348
383
  if (adjacentWrap(el)) continue;
349
384
 
350
- const key = anchorKey(klass, el);
351
- if (findWrap(key)) continue;
385
+ const key = makeAnchorKey(klass, el);
386
+ if (findWrap(key)) continue; // déjà là → pas de pickId inutile
352
387
 
353
388
  const id = pickId(poolKey);
354
- if (!id) continue;
389
+ if (!id) continue; // pool momentanément épuisé, on continue (items suivants ≠ besoin d'id)
355
390
 
356
391
  const w = insertAfter(el, id, klass, key);
357
392
  if (w) { observePh(id); inserted++; }
@@ -363,6 +398,7 @@
363
398
 
364
399
  function getIO() {
365
400
  if (S.io) return S.io;
401
+ const margin = isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP;
366
402
  try {
367
403
  S.io = new IntersectionObserver(entries => {
368
404
  for (const e of entries) {
@@ -371,7 +407,7 @@
371
407
  const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
372
408
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
373
409
  }
374
- }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
410
+ }, { root: null, rootMargin: margin, threshold: 0 });
375
411
  } catch (_) { S.io = null; }
376
412
  return S.io;
377
413
  }
@@ -422,6 +458,7 @@
422
458
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
423
459
  S.lastShow.set(id, t);
424
460
 
461
+ // Horodater le show sur le wrap pour grace period + emptyCheck
425
462
  try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
426
463
 
427
464
  window.ezstandalone = window.ezstandalone || {};
@@ -442,6 +479,7 @@
442
479
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
443
480
  const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
444
481
  if (!wrap || !ph?.isConnected) return;
482
+ // Un show plus récent → ne pas toucher
445
483
  if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
446
484
  wrap.classList.toggle('is-empty', !isFilled(ph));
447
485
  } catch (_) {}
@@ -460,7 +498,7 @@
460
498
  const orig = ez.showAds.bind(ez);
461
499
  ez.showAds = function (...args) {
462
500
  if (isBlocked()) return;
463
- const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
501
+ const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
464
502
  const seen = new Set();
465
503
  for (const v of ids) {
466
504
  const id = parseInt(v, 10);
@@ -479,7 +517,7 @@
479
517
  }
480
518
  }
481
519
 
482
- // ── Core ───────────────────────────────────────────────────────────────────
520
+ // ── Core run ───────────────────────────────────────────────────────────────
483
521
 
484
522
  async function runCore() {
485
523
  if (isBlocked()) return 0;
@@ -494,9 +532,10 @@
494
532
 
495
533
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
496
534
  if (!normBool(cfgEnable)) return 0;
535
+ const items = getItems();
497
536
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
498
537
  pruneOrphans(klass);
499
- const n = injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
538
+ const n = injectBetween(klass, items, interval, normBool(cfgShowFirst), poolKey);
500
539
  if (n) decluster(klass);
501
540
  return n;
502
541
  };
@@ -509,13 +548,14 @@
509
548
  'ezoic-ad-between', getTopics,
510
549
  cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
511
550
  );
512
- return exec(
551
+ if (kind === 'categories') return exec(
513
552
  'ezoic-ad-categories', getCategories,
514
553
  cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
515
554
  );
555
+ return 0;
516
556
  }
517
557
 
518
- // ── Scheduler ──────────────────────────────────────────────────────────────
558
+ // ── Scheduler / Burst ──────────────────────────────────────────────────────
519
559
 
520
560
  function scheduleRun(cb) {
521
561
  if (S.runQueued) return;
@@ -525,7 +565,7 @@
525
565
  if (S.pageKey && pageKey() !== S.pageKey) return;
526
566
  let n = 0;
527
567
  try { n = await runCore(); } catch (_) {}
528
- cb?.(n);
568
+ try { cb?.(n); } catch (_) {}
529
569
  });
530
570
  }
531
571
 
@@ -533,8 +573,10 @@
533
573
  if (isBlocked()) return;
534
574
  const t = ts();
535
575
  if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
536
- S.lastBurstTs = t;
537
- S.pageKey = pageKey();
576
+ S.lastBurstTs = t;
577
+
578
+ const pk = pageKey();
579
+ S.pageKey = pk;
538
580
  S.burstDeadline = t + 2000;
539
581
 
540
582
  if (S.burstActive) return;
@@ -542,7 +584,7 @@
542
584
  S.burstCount = 0;
543
585
 
544
586
  const step = () => {
545
- if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
587
+ if (pageKey() !== pk || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
546
588
  S.burstActive = false; return;
547
589
  }
548
590
  S.burstCount++;
@@ -554,10 +596,14 @@
554
596
  step();
555
597
  }
556
598
 
557
- // ── Cleanup navigation ─────────────────────────────────────────────────────
599
+ // ── Cleanup (navigation ajaxify) ───────────────────────────────────────────
558
600
 
559
601
  function cleanup() {
560
602
  blockedUntil = ts() + 1500;
603
+
604
+ // Disconnect observers to avoid doing work while ajaxify swaps content
605
+ try { if (S.domObs) { S.domObs.disconnect(); S.domObs = null; } } catch (_) {}
606
+ try { if (window.__nbbTcfObs) { window.__nbbTcfObs.disconnect(); window.__nbbTcfObs = null; } } catch (_) {}
561
607
  mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
562
608
  S.cfg = null;
563
609
  S.pools = { topics: [], posts: [], categories: [] };
@@ -571,17 +617,19 @@
571
617
  S.runQueued = false;
572
618
  }
573
619
 
574
- // ── MutationObserver ───────────────────────────────────────────────────────
620
+ // ── DOM Observer ───────────────────────────────────────────────────────────
575
621
 
576
622
  function ensureDomObserver() {
577
623
  if (S.domObs) return;
578
- const allSel = [SEL.post, SEL.topic, SEL.category];
579
624
  S.domObs = new MutationObserver(muts => {
580
625
  if (S.mutGuard > 0 || isBlocked()) return;
581
626
  for (const m of muts) {
627
+ if (!m.addedNodes?.length) continue;
582
628
  for (const n of m.addedNodes) {
583
629
  if (n.nodeType !== 1) continue;
584
- if (allSel.some(s => n.matches?.(s) || n.querySelector?.(s))) {
630
+ if (n.matches?.(SEL.post) || n.querySelector?.(SEL.post) ||
631
+ n.matches?.(SEL.topic) || n.querySelector?.(SEL.topic) ||
632
+ n.matches?.(SEL.category) || n.querySelector?.(SEL.category)) {
585
633
  requestBurst(); return;
586
634
  }
587
635
  }
@@ -593,6 +641,7 @@
593
641
  // ── Utilitaires ────────────────────────────────────────────────────────────
594
642
 
595
643
  function muteConsole() {
644
+ if (!S.cfg || !S.cfg.muteEzoicConsole) return;
596
645
  if (window.__nbbEzMuted) return;
597
646
  window.__nbbEzMuted = true;
598
647
  const MUTED = ['[EzoicAds JS]: Placeholder Id', 'Debugger iframe already exists', `with id ${PH_PREFIX}`];
@@ -607,21 +656,29 @@
607
656
  }
608
657
 
609
658
  function ensureTcfLocator() {
610
- // L'iframe __tcfapiLocator route les appels postMessage du CMP.
611
- // En navigation ajaxify, NodeBB peut la retirer du DOM → erreurs CMP.
612
- // Un MutationObserver la recrée dès qu'elle disparaît.
659
+ // Le CMP utilise une iframe nommée __tcfapiLocator pour router les
660
+ // postMessage TCF. En navigation ajaxify, NodeBB peut retirer cette
661
+ // iframe du DOM (vidage partiel du body), ce qui provoque :
662
+ // "Cannot read properties of null (reading 'postMessage')"
663
+ // "Cannot set properties of null (setting 'addtlConsent')"
664
+ // Solution : la recrée immédiatement si elle disparaît, via un observer.
613
665
  try {
614
666
  if (!window.__tcfapi && !window.__cmp) return;
667
+
615
668
  const inject = () => {
616
669
  if (document.getElementById('__tcfapiLocator')) return;
617
670
  const f = document.createElement('iframe');
618
671
  f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
619
672
  (document.body || document.documentElement).appendChild(f);
620
673
  };
674
+
621
675
  inject();
676
+
677
+ // Observer dédié — si quelqu'un retire l'iframe, on la remet.
622
678
  if (!window.__nbbTcfObs) {
623
- window.__nbbTcfObs = new MutationObserver(inject);
624
- window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
679
+ window.__nbbTcfObs = new MutationObserver(() => inject());
680
+ window.__nbbTcfObs.observe(document.documentElement,
681
+ { childList: true, subtree: true });
625
682
  }
626
683
  } catch (_) {}
627
684
  }
@@ -631,10 +688,10 @@
631
688
  const head = document.head;
632
689
  if (!head) return;
633
690
  for (const [rel, href, cors] of [
634
- ['preconnect', 'https://g.ezoic.net', true ],
635
- ['preconnect', 'https://go.ezoic.net', true ],
636
- ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
637
- ['preconnect', 'https://pagead2.googlesyndication.com', true ],
691
+ ['preconnect', 'https://g.ezoic.net', true],
692
+ ['preconnect', 'https://go.ezoic.net', true],
693
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
694
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
638
695
  ['dns-prefetch', 'https://g.ezoic.net', false],
639
696
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
640
697
  ]) {
@@ -648,7 +705,7 @@
648
705
  }
649
706
  }
650
707
 
651
- // ── Bindings ───────────────────────────────────────────────────────────────
708
+ // ── Bindings NodeBB ────────────────────────────────────────────────────────
652
709
 
653
710
  function bindNodeBB() {
654
711
  const $ = window.jQuery;
@@ -659,16 +716,19 @@
659
716
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
660
717
  S.pageKey = pageKey();
661
718
  blockedUntil = 0;
662
- muteConsole(); ensureTcfLocator(); warmNetwork();
663
- patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
719
+ muteConsole(); ensureTcfLocator(); warmNetwork(); patchShowAds();
720
+ getIO(); ensureDomObserver(); requestBurst();
664
721
  });
665
722
 
666
- const burstEvts = [
667
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
723
+ const BURST_EVENTS = [
724
+ 'action:ajaxify.contentLoaded',
725
+ 'action:posts.loaded', 'action:topics.loaded',
668
726
  'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
669
727
  ].map(e => `${e}.nbbEzoic`).join(' ');
670
- $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
671
728
 
729
+ $(window).on(BURST_EVENTS, () => { if (!isBlocked()) requestBurst(); });
730
+
731
+ // Hooks AMD (NodeBB 4.x — redondant avec jQuery events mais sans risque)
672
732
  try {
673
733
  require(['hooks'], hooks => {
674
734
  if (typeof hooks?.on !== 'function') return;
@@ -684,6 +744,11 @@
684
744
  let ticking = false;
685
745
  window.addEventListener('scroll', () => {
686
746
  if (ticking) return;
747
+ // Scroll bursts only matter on pages where content streams in
748
+ const kind = getKind();
749
+ if (kind !== 'topic' && kind !== 'categoryTopics' && kind !== 'categories') return;
750
+ if (isBlocked()) return;
751
+
687
752
  ticking = true;
688
753
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
689
754
  }, { passive: true });
package/public/style.css CHANGED
@@ -71,8 +71,8 @@
71
71
  overflow: hidden !important;
72
72
  }
73
73
 
74
- /* ── Ezoic global (hors de nos wraps) ────────────────────────────────────── */
75
- .ezoic-ad {
76
- margin: 0 !important;
77
- padding: 0 !important;
78
- }
74
+ /* ── Note ───────────────────────────────────────────────────────────── */
75
+ /*
76
+ On évite volontairement de cibler .ezoic-ad globalement,
77
+ pour ne pas impacter d'autres emplacements/optimisations Ezoic hors plugin.
78
+ */
@@ -8,7 +8,7 @@
8
8
  <input class="form-check-input" type="checkbox" id="enableBetweenAds" name="enableBetweenAds" {enableBetweenAds_checked}>
9
9
  <label class="form-check-label" for="enableBetweenAds">Activer les pubs entre les posts</label>
10
10
  <div class="form-check mt-2">
11
- <input class="form-check-input" type="checkbox" name="showFirstTopicAd" />
11
+ <input class="form-check-input" type="checkbox" name="showFirstTopicAd" {showFirstTopicAd_checked} />
12
12
  <label class="form-check-label">Afficher une pub après le 1er sujet</label>
13
13
  </div>
14
14
  </div>
@@ -16,7 +16,7 @@
16
16
  <div class="mb-3">
17
17
  <label class="form-label" for="placeholderIds">Pool d’IDs Ezoic (entre posts)</label>
18
18
  <textarea id="placeholderIds" name="placeholderIds" class="form-control" rows="4">{placeholderIds}</textarea>
19
- <p class="form-text">Un ID par ligne (ou séparé par virgules/espaces). Le nombre d’IDs = nombre max de pubs simultanées.</p>
19
+ <p class="form-text">Un ID par ligne (ou séparé par virgules/espaces).</p>
20
20
  </div>
21
21
 
22
22
  <div class="mb-3">
@@ -33,10 +33,10 @@
33
33
  <p class="form-text">Insère des pubs entre les catégories sur la page d’accueil (liste des catégories).</p>
34
34
 
35
35
  <div class="form-check mb-3">
36
- <input class="form-check-input" type="checkbox" id="enableCategoryAds" name="enableCategoryAds">
36
+ <input class="form-check-input" type="checkbox" id="enableCategoryAds" name="enableCategoryAds" {enableCategoryAds_checked}>
37
37
  <label class="form-check-label" for="enableCategoryAds">Activer les pubs entre les catégories</label>
38
38
  <div class="form-check mt-2">
39
- <input class="form-check-input" type="checkbox" name="showFirstCategoryAd" />
39
+ <input class="form-check-input" type="checkbox" name="showFirstCategoryAd" {showFirstCategoryAd_checked} />
40
40
  <label class="form-check-label">Afficher une pub après la 1ère catégorie</label>
41
41
  </div>
42
42
  </div>
@@ -44,7 +44,7 @@
44
44
  <div class="mb-3">
45
45
  <label class="form-label" for="categoryPlaceholderIds">Pool d’IDs Ezoic (catégories)</label>
46
46
  <textarea id="categoryPlaceholderIds" name="categoryPlaceholderIds" class="form-control" rows="4">{categoryPlaceholderIds}</textarea>
47
- <p class="form-text">IDs numériques, un par ligne. Utilise un pool dédié (différent des pools topics/messages).</p>
47
+ <p class="form-text">IDs numériques, un par ligne (ou séparés par virgules/espaces). Utilise un pool dédié (différent des pools topics/messages).</p>
48
48
  </div>
49
49
 
50
50
  <div class="mb-3">
@@ -59,7 +59,7 @@
59
59
  <input class="form-check-input" type="checkbox" id="enableMessageAds" name="enableMessageAds" {enableMessageAds_checked}>
60
60
  <label class="form-check-label" for="enableMessageAds">Activer les pubs “message”</label>
61
61
  <div class="form-check mt-2">
62
- <input class="form-check-input" type="checkbox" name="showFirstMessageAd" />
62
+ <input class="form-check-input" type="checkbox" name="showFirstMessageAd" {showFirstMessageAd_checked} />
63
63
  <label class="form-check-label">Afficher une pub après le 1er message</label>
64
64
  </div>
65
65
  </div>
@@ -67,7 +67,7 @@
67
67
  <div class="mb-3">
68
68
  <label class="form-label" for="messagePlaceholderIds">Pool d’IDs Ezoic (message)</label>
69
69
  <textarea id="messagePlaceholderIds" name="messagePlaceholderIds" class="form-control" rows="4">{messagePlaceholderIds}</textarea>
70
- <p class="form-text">Pool séparé recommandé pour éviter la réutilisation d’IDs. IMPORTANT : ne réutilise pas les mêmes IDs dans les deux pools.</p>
70
+ <p class="form-text">Pool séparé recommandé pour limiter la réutilisation d’IDs entre emplacements.</p>
71
71
  </div>
72
72
 
73
73
  <div class="mb-3">
@@ -82,12 +82,22 @@
82
82
  <label class="form-label" for="excludedGroups">Groupes exclus</label>
83
83
  <select id="excludedGroups" name="excludedGroups" class="form-select" multiple>
84
84
  <!-- BEGIN allGroups -->
85
- <option value="{allGroups.name}">{allGroups.name}</option>
85
+ <option value="{allGroups.name}" {allGroups.selected}>{allGroups.name}</option>
86
86
  <!-- END allGroups -->
87
87
  </select>
88
88
  <p class="form-text">Si l’utilisateur appartient à un de ces groupes, aucune pub n’est injectée.</p>
89
89
  </div>
90
90
 
91
+
92
+ <hr/>
93
+
94
+ <h4 class="mt-3">Options avancées</h4>
95
+
96
+ <div class="form-check mb-3">
97
+ <input class="form-check-input" type="checkbox" id="muteEzoicConsole" name="muteEzoicConsole" {muteEzoicConsole_checked}>
98
+ <label class="form-check-label" for="muteEzoicConsole">Filtrer certains logs Ezoic dans la console (évite le bruit)</label>
99
+ <p class="form-text">Désactivé par défaut pour ne pas impacter les logs d’autres plugins.</p>
100
+ </div>
91
101
  <button id="save" class="btn btn-primary">Enregistrer</button>
92
102
  </form>
93
103
  </div>