nodebb-plugin-ezoic-infinite 1.7.16 → 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 +68 -12
- package/package.json +13 -4
- package/public/client.js +25 -7
- package/public/style.css +5 -5
- package/public/templates/admin/plugins/ezoic-infinite.tpl +18 -8
package/library.js
CHANGED
|
@@ -9,8 +9,10 @@ const plugin = {};
|
|
|
9
9
|
|
|
10
10
|
function normalizeExcludedGroups(value) {
|
|
11
11
|
if (!value) return [];
|
|
12
|
-
|
|
13
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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.
|
|
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
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
pools: { topics: [], posts: [], categories: [] },
|
|
85
85
|
cursors: { topics: 0, posts: 0, categories: 0 },
|
|
86
86
|
mountedIds: new Set(), // IDs Ezoic actuellement dans le DOM
|
|
87
|
+
reuseSeq: new Map(), // id → compteur pour générer des IDs DOM uniques
|
|
87
88
|
lastShow: new Map(), // id → timestamp dernier show
|
|
88
89
|
|
|
89
90
|
io: null,
|
|
@@ -129,14 +130,17 @@
|
|
|
129
130
|
|
|
130
131
|
function parseIds(raw) {
|
|
131
132
|
const out = [], seen = new Set();
|
|
132
|
-
for (const v of String(raw || '').split(
|
|
133
|
+
for (const v of String(raw || '').split(/[\s,]+/).map(s => s.trim()).filter(Boolean)) {
|
|
133
134
|
const n = parseInt(v, 10);
|
|
134
135
|
if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
|
|
135
136
|
}
|
|
136
137
|
return out;
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
|
|
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;
|
|
140
144
|
|
|
141
145
|
const isFilled = (n) =>
|
|
142
146
|
!!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
|
|
@@ -229,13 +233,19 @@
|
|
|
229
233
|
const i = S.cursors[poolKey] % pool.length;
|
|
230
234
|
S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
|
|
231
235
|
const id = pool[i];
|
|
232
|
-
|
|
236
|
+
return id;
|
|
233
237
|
}
|
|
234
238
|
return null;
|
|
235
239
|
}
|
|
236
240
|
|
|
237
241
|
// ── Wraps DOM ──────────────────────────────────────────────────────────────
|
|
238
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
|
+
|
|
239
249
|
function makeWrap(id, klass, key) {
|
|
240
250
|
const w = document.createElement('div');
|
|
241
251
|
w.className = `${WRAP_CLASS} ${klass}`;
|
|
@@ -244,7 +254,7 @@
|
|
|
244
254
|
w.setAttribute(A_CREATED, String(ts()));
|
|
245
255
|
w.style.cssText = 'width:100%;display:block;';
|
|
246
256
|
const ph = document.createElement('div');
|
|
247
|
-
ph.id =
|
|
257
|
+
ph.id = nextDomPlaceholderId(id);
|
|
248
258
|
ph.setAttribute('data-ezoic-id', String(id));
|
|
249
259
|
w.appendChild(ph);
|
|
250
260
|
return w;
|
|
@@ -253,8 +263,6 @@
|
|
|
253
263
|
function insertAfter(el, id, klass, key) {
|
|
254
264
|
if (!el?.insertAdjacentElement) return null;
|
|
255
265
|
if (findWrap(key)) return null; // ancre déjà présente
|
|
256
|
-
if (S.mountedIds.has(id)) return null; // id déjà monté
|
|
257
|
-
if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
|
|
258
266
|
const w = makeWrap(id, klass, key);
|
|
259
267
|
mutate(() => el.insertAdjacentElement('afterend', w));
|
|
260
268
|
S.mountedIds.add(id);
|
|
@@ -269,7 +277,7 @@
|
|
|
269
277
|
// unobserve(null) corrompt l'état interne de l'IO (pubads lève ensuite
|
|
270
278
|
// "parameter 1 is not of type Element" sur le prochain observe).
|
|
271
279
|
try {
|
|
272
|
-
const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
|
|
280
|
+
const ph = w.querySelector('[data-ezoic-id]') || w.querySelector(`[id^="${PH_PREFIX}"]`);
|
|
273
281
|
if (ph instanceof Element) S.io?.unobserve(ph);
|
|
274
282
|
} catch (_) {}
|
|
275
283
|
w.remove();
|
|
@@ -592,6 +600,10 @@
|
|
|
592
600
|
|
|
593
601
|
function cleanup() {
|
|
594
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 (_) {}
|
|
595
607
|
mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
|
|
596
608
|
S.cfg = null;
|
|
597
609
|
S.pools = { topics: [], posts: [], categories: [] };
|
|
@@ -629,6 +641,7 @@
|
|
|
629
641
|
// ── Utilitaires ────────────────────────────────────────────────────────────
|
|
630
642
|
|
|
631
643
|
function muteConsole() {
|
|
644
|
+
if (!S.cfg || !S.cfg.muteEzoicConsole) return;
|
|
632
645
|
if (window.__nbbEzMuted) return;
|
|
633
646
|
window.__nbbEzMuted = true;
|
|
634
647
|
const MUTED = ['[EzoicAds JS]: Placeholder Id', 'Debugger iframe already exists', `with id ${PH_PREFIX}`];
|
|
@@ -731,6 +744,11 @@
|
|
|
731
744
|
let ticking = false;
|
|
732
745
|
window.addEventListener('scroll', () => {
|
|
733
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
|
+
|
|
734
752
|
ticking = true;
|
|
735
753
|
requestAnimationFrame(() => { ticking = false; requestBurst(); });
|
|
736
754
|
}, { passive: true });
|
package/public/style.css
CHANGED
|
@@ -71,8 +71,8 @@
|
|
|
71
71
|
overflow: hidden !important;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/* ──
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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)
|
|
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
|
|
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>
|