nodebb-plugin-facebook-post 1.0.33 → 1.0.35
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/CLAUDE.md +2 -1
- package/library.js +136 -107
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/static/lib/composer.js +8 -132
- package/static/templates/admin/facebook-post.tpl +6 -20
package/CLAUDE.md
CHANGED
|
@@ -20,8 +20,9 @@ There is no build step for this plugin. NodeBB handles asset bundling.
|
|
|
20
20
|
|
|
21
21
|
**Environment variables (required at NodeBB startup):**
|
|
22
22
|
- `NODEBB_FB_PAGE_ID` – Facebook Page ID
|
|
23
|
-
- `NODEBB_FB_PAGE_ACCESS_TOKEN` – Page access token
|
|
23
|
+
- `NODEBB_FB_PAGE_ACCESS_TOKEN` – Page access token (also used for Instagram Graph API)
|
|
24
24
|
- `NODEBB_FB_GRAPH_VERSION` – optional, defaults to `v25.0`
|
|
25
|
+
- `NODEBB_IG_USER_ID` – optional; Instagram Business Account ID. If omitted, fetched automatically from the linked Page via `/{pageId}?fields=instagram_business_account`
|
|
25
26
|
|
|
26
27
|
## Architecture
|
|
27
28
|
|
package/library.js
CHANGED
|
@@ -14,7 +14,6 @@ const DEFAULT_SETTINGS = {
|
|
|
14
14
|
categoriesWhitelist: '',
|
|
15
15
|
minimumReputation: 0,
|
|
16
16
|
maxImages: 4,
|
|
17
|
-
enablePlaceTagging: false,
|
|
18
17
|
};
|
|
19
18
|
|
|
20
19
|
function bool(v) {
|
|
@@ -39,6 +38,7 @@ function readEnv() {
|
|
|
39
38
|
fbGraphVersion: trimStr(env.NODEBB_FB_GRAPH_VERSION) || 'v25.0',
|
|
40
39
|
fbPageId: trimStr(env.NODEBB_FB_PAGE_ID),
|
|
41
40
|
fbPageAccessToken: trimStr(env.NODEBB_FB_PAGE_ACCESS_TOKEN),
|
|
41
|
+
igUserId: trimStr(env.NODEBB_IG_USER_ID),
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -52,12 +52,12 @@ async function loadSettings() {
|
|
|
52
52
|
settings.minimumReputation = int(settings.minimumReputation, 0);
|
|
53
53
|
settings.maxImages = int(settings.maxImages, DEFAULT_SETTINGS.maxImages);
|
|
54
54
|
settings.categoriesWhitelist = trimStr(settings.categoriesWhitelist);
|
|
55
|
-
settings.enablePlaceTagging = bool(settings.enablePlaceTagging);
|
|
56
55
|
|
|
57
56
|
const env = readEnv();
|
|
58
57
|
settings.fbGraphVersion = env.fbGraphVersion;
|
|
59
58
|
settings.fbPageId = env.fbPageId;
|
|
60
59
|
settings.fbPageAccessToken = env.fbPageAccessToken;
|
|
60
|
+
settings.igUserId = env.igUserId;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function getForumBaseUrl() {
|
|
@@ -153,7 +153,7 @@ async function userIsAllowed(uid) {
|
|
|
153
153
|
const ok = await Groups.isMember(uid, groupName);
|
|
154
154
|
if (ok) return true;
|
|
155
155
|
} catch {
|
|
156
|
-
// ignore
|
|
156
|
+
// ignore individual group check errors
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
return false;
|
|
@@ -209,14 +209,13 @@ async function uploadPhotoToFacebook(urlAbs) {
|
|
|
209
209
|
return resp.data && resp.data.id;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
async function publishFeedPost({ message, link, photoIds
|
|
212
|
+
async function publishFeedPost({ message, link, photoIds }) {
|
|
213
213
|
const endpoint = `https://graph.facebook.com/${settings.fbGraphVersion}/${settings.fbPageId}/feed`;
|
|
214
214
|
|
|
215
215
|
const form = new URLSearchParams();
|
|
216
216
|
form.append('message', String(message || ''));
|
|
217
217
|
form.append('access_token', String(settings.fbPageAccessToken));
|
|
218
218
|
if (link) form.append('link', String(link));
|
|
219
|
-
if (placeId) form.append('place', String(placeId));
|
|
220
219
|
if (Array.isArray(photoIds) && photoIds.length) {
|
|
221
220
|
photoIds.forEach((id, idx) => {
|
|
222
221
|
form.append(`attached_media[${idx}]`, JSON.stringify({ media_fbid: id }));
|
|
@@ -230,6 +229,100 @@ async function publishFeedPost({ message, link, photoIds, placeId }) {
|
|
|
230
229
|
return resp.data && resp.data.id;
|
|
231
230
|
}
|
|
232
231
|
|
|
232
|
+
let cachedIgUserId = null;
|
|
233
|
+
|
|
234
|
+
async function getInstagramUserId() {
|
|
235
|
+
if (settings.igUserId) return settings.igUserId;
|
|
236
|
+
if (cachedIgUserId) return cachedIgUserId;
|
|
237
|
+
const resp = await axios.get(
|
|
238
|
+
`https://graph.facebook.com/${settings.fbGraphVersion}/${settings.fbPageId}`,
|
|
239
|
+
{
|
|
240
|
+
params: { fields: 'instagram_business_account', access_token: settings.fbPageAccessToken },
|
|
241
|
+
timeout: 10000,
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
const igId = resp.data && resp.data.instagram_business_account && resp.data.instagram_business_account.id;
|
|
245
|
+
if (!igId) throw new Error('No Instagram Business Account linked to this Facebook Page');
|
|
246
|
+
cachedIgUserId = igId;
|
|
247
|
+
return igId;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function postToInstagram(ctx) {
|
|
251
|
+
const rawContent = ctx.post.content || '';
|
|
252
|
+
const topicTitle = decodeHtmlEntities(ctx.topic.title || 'Nouveau sujet');
|
|
253
|
+
const author = ctx.user.username || 'Quelqu\u2019un';
|
|
254
|
+
const link = ctx.topicUrlAbs;
|
|
255
|
+
|
|
256
|
+
const imageUrls = extractImageUrlsFromContent(rawContent)
|
|
257
|
+
.filter(isForumHosted)
|
|
258
|
+
.slice(0, Math.max(0, settings.maxImages));
|
|
259
|
+
|
|
260
|
+
// Instagram feed posts require at least one image
|
|
261
|
+
if (!imageUrls.length) return null;
|
|
262
|
+
|
|
263
|
+
const excerpt = settings.includeExcerpt ? sanitizeExcerpt(rawContent, settings.excerptMaxLen) : '';
|
|
264
|
+
const lines = [`\uD83D\uDCDD ${topicTitle}`];
|
|
265
|
+
if (excerpt) lines.push(excerpt);
|
|
266
|
+
lines.push(`\uD83D\uDD17 ${link}`);
|
|
267
|
+
lines.push(`\u2014 ${author}`);
|
|
268
|
+
const caption = lines.join('\n\n');
|
|
269
|
+
|
|
270
|
+
const igUserId = await getInstagramUserId();
|
|
271
|
+
const baseUrl = `https://graph.facebook.com/${settings.fbGraphVersion}/${igUserId}`;
|
|
272
|
+
|
|
273
|
+
if (imageUrls.length === 1) {
|
|
274
|
+
const containerResp = await axios.post(`${baseUrl}/media`, null, {
|
|
275
|
+
params: { image_url: imageUrls[0], caption, access_token: settings.fbPageAccessToken },
|
|
276
|
+
timeout: 20000,
|
|
277
|
+
});
|
|
278
|
+
const containerId = containerResp.data && containerResp.data.id;
|
|
279
|
+
if (!containerId) return null;
|
|
280
|
+
|
|
281
|
+
const publishResp = await axios.post(`${baseUrl}/media_publish`, null, {
|
|
282
|
+
params: { creation_id: containerId, access_token: settings.fbPageAccessToken },
|
|
283
|
+
timeout: 20000,
|
|
284
|
+
});
|
|
285
|
+
return publishResp.data && publishResp.data.id;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Carousel
|
|
289
|
+
const childIds = [];
|
|
290
|
+
for (const url of imageUrls) {
|
|
291
|
+
// eslint-disable-next-line no-await-in-loop
|
|
292
|
+
const childResp = await axios.post(`${baseUrl}/media`, null, {
|
|
293
|
+
params: {
|
|
294
|
+
image_url: url,
|
|
295
|
+
media_type: 'IMAGE',
|
|
296
|
+
is_carousel_item: true,
|
|
297
|
+
access_token: settings.fbPageAccessToken,
|
|
298
|
+
},
|
|
299
|
+
timeout: 20000,
|
|
300
|
+
});
|
|
301
|
+
const childId = childResp.data && childResp.data.id;
|
|
302
|
+
if (childId) childIds.push(childId);
|
|
303
|
+
}
|
|
304
|
+
if (!childIds.length) return null;
|
|
305
|
+
|
|
306
|
+
const form = new URLSearchParams();
|
|
307
|
+
form.append('media_type', 'CAROUSEL');
|
|
308
|
+
form.append('caption', caption);
|
|
309
|
+
childIds.forEach(id => form.append('children', id));
|
|
310
|
+
form.append('access_token', settings.fbPageAccessToken);
|
|
311
|
+
|
|
312
|
+
const carouselResp = await axios.post(`${baseUrl}/media`, form, {
|
|
313
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
314
|
+
timeout: 20000,
|
|
315
|
+
});
|
|
316
|
+
const carouselId = carouselResp.data && carouselResp.data.id;
|
|
317
|
+
if (!carouselId) return null;
|
|
318
|
+
|
|
319
|
+
const publishResp = await axios.post(`${baseUrl}/media_publish`, null, {
|
|
320
|
+
params: { creation_id: carouselId, access_token: settings.fbPageAccessToken },
|
|
321
|
+
timeout: 20000,
|
|
322
|
+
});
|
|
323
|
+
return publishResp.data && publishResp.data.id;
|
|
324
|
+
}
|
|
325
|
+
|
|
233
326
|
async function postToFacebook(ctx) {
|
|
234
327
|
const rawContent = ctx.post.content || '';
|
|
235
328
|
const topicTitle = decodeHtmlEntities(ctx.topic.title || 'Nouveau sujet');
|
|
@@ -255,7 +348,7 @@ async function postToFacebook(ctx) {
|
|
|
255
348
|
if (id) photoIds.push(id);
|
|
256
349
|
}
|
|
257
350
|
|
|
258
|
-
return publishFeedPost({ message, link, photoIds
|
|
351
|
+
return publishFeedPost({ message, link, photoIds });
|
|
259
352
|
}
|
|
260
353
|
|
|
261
354
|
const Plugin = {};
|
|
@@ -303,44 +396,11 @@ Plugin.init = async function (params) {
|
|
|
303
396
|
const allowedGroups = parseAllowedGroupsList();
|
|
304
397
|
if (!allowedGroups.length) return res.json({ allowed: false, reason: 'no_groups_configured' });
|
|
305
398
|
const ok = await userIsAllowed(uid);
|
|
306
|
-
return res.json({ allowed: ok, reason: ok ? 'ok' : 'not_in_group'
|
|
399
|
+
return res.json({ allowed: ok, reason: ok ? 'ok' : 'not_in_group' });
|
|
307
400
|
} catch {
|
|
308
401
|
return res.json({ allowed: false, reason: 'error' });
|
|
309
402
|
}
|
|
310
403
|
});
|
|
311
|
-
|
|
312
|
-
router.get('/api/facebook-post/search-place', middleware.ensureLoggedIn, async (req, res) => {
|
|
313
|
-
const winston = require.main.require('winston');
|
|
314
|
-
res.set('Cache-Control', 'no-store');
|
|
315
|
-
try {
|
|
316
|
-
await loadSettings();
|
|
317
|
-
if (!settings.enabled || !settings.enablePlaceTagging || !settings.fbPageAccessToken) {
|
|
318
|
-
winston.info(`[facebook-post] search-place: abandon — enabled=${settings.enabled} enablePlaceTagging=${settings.enablePlaceTagging} token=${!!settings.fbPageAccessToken}`);
|
|
319
|
-
return res.json({ results: [] });
|
|
320
|
-
}
|
|
321
|
-
const q = trimStr(req.query.q);
|
|
322
|
-
if (q.length < 2) return res.json({ results: [] });
|
|
323
|
-
winston.info(`[facebook-post] search-place: requête q="${q}"`);
|
|
324
|
-
const resp = await axios.get(`https://graph.facebook.com/${settings.fbGraphVersion}/search`, {
|
|
325
|
-
params: {
|
|
326
|
-
type: 'place',
|
|
327
|
-
q,
|
|
328
|
-
fields: 'name,location,id',
|
|
329
|
-
access_token: settings.fbPageAccessToken,
|
|
330
|
-
limit: 8,
|
|
331
|
-
},
|
|
332
|
-
timeout: 8000,
|
|
333
|
-
});
|
|
334
|
-
const results = (resp.data && resp.data.data) || [];
|
|
335
|
-
winston.info(`[facebook-post] search-place: ${results.length} résultat(s) pour "${q}"`);
|
|
336
|
-
return res.json({ results });
|
|
337
|
-
} catch (e) {
|
|
338
|
-
const detail = e?.response ? JSON.stringify(e.response.data) : (e?.message || e);
|
|
339
|
-
winston.error(`[facebook-post] search-place: ERREUR: ${detail}`);
|
|
340
|
-
return res.json({ results: [], error: detail });
|
|
341
|
-
}
|
|
342
|
-
});
|
|
343
|
-
|
|
344
404
|
};
|
|
345
405
|
|
|
346
406
|
Plugin.addAdminNavigation = async function (header) {
|
|
@@ -354,94 +414,63 @@ Plugin.addAdminNavigation = async function (header) {
|
|
|
354
414
|
};
|
|
355
415
|
|
|
356
416
|
Plugin.onPostCreate = async function (hookData) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
if (!hookData?.post || !hookData?.data) {
|
|
360
|
-
winston.info('[facebook-post] onPostCreate: hookData manquant (post ou data absent)');
|
|
361
|
-
return hookData;
|
|
362
|
-
}
|
|
363
|
-
hookData.post.fbPostEnabled = bool(hookData.data.fbPostEnabled);
|
|
364
|
-
const fbPlaceId = trimStr(hookData.data.fbPlaceId);
|
|
365
|
-
if (fbPlaceId) hookData.post.fbPlaceId = fbPlaceId;
|
|
366
|
-
winston.info(`[facebook-post] onPostCreate: pid=${hookData.post.pid} fbPostEnabled=${hookData.post.fbPostEnabled} fbPlaceId=${hookData.post.fbPlaceId || '(none)'}`);
|
|
367
|
-
} catch (e) {
|
|
368
|
-
winston.error(`[facebook-post] onPostCreate erreur: ${e?.message || e}`);
|
|
369
|
-
}
|
|
417
|
+
if (!hookData?.post || !hookData?.data) return hookData;
|
|
418
|
+
hookData.post.fbPostEnabled = bool(hookData.data.fbPostEnabled);
|
|
370
419
|
return hookData;
|
|
371
420
|
};
|
|
372
421
|
|
|
373
422
|
Plugin.onPostSave = async function (hookData) {
|
|
374
423
|
const winston = require.main.require('winston');
|
|
375
|
-
winston.info('[facebook-post] onPostSave: hook déclenché');
|
|
376
424
|
try {
|
|
377
425
|
await loadSettings();
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (!settings.enabled) {
|
|
381
|
-
winston.info('[facebook-post] onPostSave: abandon — plugin désactivé dans les paramètres');
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
426
|
+
if (!settings.enabled) return;
|
|
384
427
|
|
|
385
428
|
const rawPost = hookData && hookData.post ? hookData.post : hookData;
|
|
386
|
-
winston.info(`[facebook-post] onPostSave: données brutes reçues — pid=${rawPost?.pid} fbPostEnabled=${rawPost?.fbPostEnabled}`);
|
|
387
|
-
|
|
388
429
|
const ctx = await getPostContext(rawPost);
|
|
389
|
-
if (!ctx)
|
|
390
|
-
winston.warn('[facebook-post] onPostSave: abandon — impossible de récupérer le contexte du post (pid invalide ?)');
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
430
|
+
if (!ctx) return;
|
|
393
431
|
|
|
394
|
-
// Restore ephemeral
|
|
432
|
+
// Restore ephemeral field set by onPostCreate (not persisted to DB)
|
|
395
433
|
ctx.post.fbPostEnabled = rawPost.fbPostEnabled;
|
|
396
|
-
if (rawPost.fbPlaceId) ctx.post.fbPlaceId = rawPost.fbPlaceId;
|
|
397
|
-
|
|
398
|
-
winston.info(`[facebook-post] onPostSave: contexte récupéré — pid=${ctx.post.pid} tid=${ctx.topic.tid} cid=${ctx.topic.cid} uid=${ctx.user.uid} isMainPost=${ctx.post.isMainPost} index=${ctx.post.index} mainPid=${ctx.topic.mainPid} reputation=${ctx.user.reputation}`);
|
|
399
434
|
|
|
400
|
-
|
|
401
|
-
if (!
|
|
402
|
-
const groups = parseAllowedGroupsList();
|
|
403
|
-
winston.info(`[facebook-post] onPostSave: abandon — uid=${ctx.post.uid} n'est pas dans les groupes autorisés: [${groups.join(', ')}]`);
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
winston.info(`[facebook-post] onPostSave: uid=${ctx.post.uid} autorisé par les groupes`);
|
|
407
|
-
|
|
408
|
-
const process = shouldProcessPost(ctx);
|
|
409
|
-
if (!process) {
|
|
410
|
-
const isFirstPost = (ctx.post.isMainPost === true) || (ctx.post.index === 0) || (String(ctx.post.pid) === String(ctx.topic.mainPid));
|
|
411
|
-
const fbEnabled = bool(ctx.post.fbPostEnabled);
|
|
412
|
-
const repOk = (ctx.user.reputation || 0) >= settings.minimumReputation;
|
|
413
|
-
const whitelist = parseCsvInts(settings.categoriesWhitelist);
|
|
414
|
-
const catOk = whitelist.length === 0 || whitelist.includes(parseInt(ctx.topic.cid, 10));
|
|
415
|
-
winston.info(`[facebook-post] onPostSave: abandon — shouldProcessPost=false. Détail: isFirstPost=${isFirstPost} fbPostEnabled=${fbEnabled} reputationOk=${repOk}(${ctx.user.reputation}>=${settings.minimumReputation}) categoryOk=${catOk}(cid=${ctx.topic.cid} whitelist=[${whitelist}])`);
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
winston.info('[facebook-post] onPostSave: shouldProcessPost=true, publication en cours…');
|
|
435
|
+
if (!(await userIsAllowed(ctx.post.uid))) return;
|
|
436
|
+
if (!shouldProcessPost(ctx)) return;
|
|
419
437
|
|
|
420
438
|
const Posts = require.main.require('./src/posts');
|
|
421
439
|
|
|
422
|
-
const
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
|
|
440
|
+
const fbAlready = await Posts.getPostField(ctx.post.pid, 'fbPostedId');
|
|
441
|
+
if (!fbAlready) {
|
|
442
|
+
try {
|
|
443
|
+
const fbId = await postToFacebook(ctx);
|
|
444
|
+
if (fbId) {
|
|
445
|
+
await Posts.setPostField(ctx.post.pid, 'fbPostedId', fbId);
|
|
446
|
+
await Posts.setPostField(ctx.post.pid, 'fbPostedAt', Date.now());
|
|
447
|
+
winston.info(`[facebook-post] facebook published pid=${ctx.post.pid} fbId=${fbId}`);
|
|
448
|
+
} else {
|
|
449
|
+
winston.warn(`[facebook-post] postToFacebook returned no id for pid=${ctx.post.pid}`);
|
|
450
|
+
}
|
|
451
|
+
} catch (fbErr) {
|
|
452
|
+
const detail = fbErr?.response ? JSON.stringify(fbErr.response.data) : (fbErr?.message || fbErr);
|
|
453
|
+
winston.error(`[facebook-post] facebook error pid=${ctx.post.pid}: ${detail}`);
|
|
454
|
+
}
|
|
426
455
|
}
|
|
427
456
|
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
457
|
+
const igAlready = await Posts.getPostField(ctx.post.pid, 'igPostedId');
|
|
458
|
+
if (!igAlready) {
|
|
459
|
+
try {
|
|
460
|
+
const igId = await postToInstagram(ctx);
|
|
461
|
+
if (igId) {
|
|
462
|
+
await Posts.setPostField(ctx.post.pid, 'igPostedId', igId);
|
|
463
|
+
await Posts.setPostField(ctx.post.pid, 'igPostedAt', Date.now());
|
|
464
|
+
winston.info(`[facebook-post] instagram published pid=${ctx.post.pid} igId=${igId}`);
|
|
465
|
+
}
|
|
466
|
+
} catch (igErr) {
|
|
467
|
+
const detail = igErr?.response ? JSON.stringify(igErr.response.data) : (igErr?.message || igErr);
|
|
468
|
+
winston.error(`[facebook-post] instagram error pid=${ctx.post.pid}: ${detail}`);
|
|
469
|
+
}
|
|
438
470
|
}
|
|
439
471
|
} catch (e) {
|
|
440
472
|
const detail = e?.response ? JSON.stringify(e.response.data) : (e?.message || e);
|
|
441
|
-
winston.error(`[facebook-post] onPostSave
|
|
442
|
-
if (e?.response?.config?.url) {
|
|
443
|
-
winston.error(`[facebook-post] onPostSave: URL appelée: ${e.response.config.url} — status: ${e.response.status}`);
|
|
444
|
-
}
|
|
473
|
+
winston.error(`[facebook-post] onPostSave error: ${detail}`);
|
|
445
474
|
}
|
|
446
475
|
};
|
|
447
476
|
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "nodebb-plugin-facebook-post",
|
|
3
3
|
"name": "Facebook Post",
|
|
4
|
-
"description": "Publie automatiquement les nouveaux topics NodeBB sur une Page Facebook fixe (images uploads
|
|
4
|
+
"description": "Publie automatiquement les nouveaux topics NodeBB sur une Page Facebook fixe (images uploads).",
|
|
5
5
|
"url": "https://example.invalid/nodebb-plugin-facebook-post",
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
package/static/lib/composer.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
/* global
|
|
1
|
+
/* global $ */
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
(function () {
|
|
5
|
-
const DEBOUNCE_MS = 400;
|
|
6
|
-
|
|
7
5
|
async function canPost() {
|
|
8
6
|
try {
|
|
9
7
|
const res = await fetch('/api/facebook-post/can-post?_=' + Date.now(), {
|
|
@@ -17,36 +15,12 @@
|
|
|
17
15
|
}
|
|
18
16
|
}
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
const res = await fetch('/api/facebook-post/search-place?q=' + encodeURIComponent(q), {
|
|
23
|
-
credentials: 'same-origin',
|
|
24
|
-
cache: 'no-store',
|
|
25
|
-
});
|
|
26
|
-
if (!res.ok) return [];
|
|
27
|
-
const data = await res.json();
|
|
28
|
-
return data.results || [];
|
|
29
|
-
} catch {
|
|
30
|
-
return [];
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function resetPlace($panel) {
|
|
35
|
-
$panel.find('.fb-place-search').removeClass('d-none').val('');
|
|
36
|
-
$panel.find('.fb-place-id').val('');
|
|
37
|
-
$panel.find('.fb-place-selected').removeClass('d-inline-flex').addClass('d-none');
|
|
38
|
-
$panel.find('.fb-place-results').hide().empty();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function injectUI($composer, perm) {
|
|
18
|
+
function injectUI($composer) {
|
|
42
19
|
if ($composer.find('[data-fbpost-btn]').length) return;
|
|
43
20
|
|
|
44
|
-
const showPlace = !!perm.enablePlaceTagging;
|
|
45
|
-
|
|
46
|
-
// ── Bouton dans la toolbar — même structure que les boutons natifs ──
|
|
47
21
|
const $li = $(`
|
|
48
22
|
<li aria-label="Publier sur Facebook" data-bs-original-title="Publier sur Facebook" data-fbpost-btn>
|
|
49
|
-
<button type="button" class="btn btn-sm btn-link text-reset
|
|
23
|
+
<button type="button" class="btn btn-sm btn-link text-reset"
|
|
50
24
|
aria-label="Publier sur Facebook" style="opacity:0.45;">
|
|
51
25
|
<i class="fab fa-facebook-f"></i>
|
|
52
26
|
</button>
|
|
@@ -54,122 +28,24 @@
|
|
|
54
28
|
`);
|
|
55
29
|
const $btn = $li.find('button');
|
|
56
30
|
|
|
57
|
-
// ── Panneau lieu (affiché sous la toolbar quand FB est actif) ──
|
|
58
|
-
const $placePanel = $(`
|
|
59
|
-
<div class="fb-place-panel d-none align-items-center gap-2 px-2 py-1"
|
|
60
|
-
style="font-size:0.85em; border-top:1px solid rgba(128,128,128,.2);">
|
|
61
|
-
<span class="text-muted">Publier sur Facebook</span>
|
|
62
|
-
${showPlace ? `
|
|
63
|
-
<div class="position-relative d-flex align-items-center gap-1 flex-grow-1 ms-2">
|
|
64
|
-
<span class="text-muted text-nowrap">Lieu (facultatif)</span>
|
|
65
|
-
<input type="text" class="form-control form-control-sm fb-place-search"
|
|
66
|
-
placeholder="Rechercher…" autocomplete="off">
|
|
67
|
-
<ul class="fb-place-results dropdown-menu p-1"
|
|
68
|
-
style="display:none; position:absolute; z-index:9999; top:100%; left:0;
|
|
69
|
-
min-width:220px; max-height:200px; overflow-y:auto;"></ul>
|
|
70
|
-
</div>
|
|
71
|
-
<span class="fb-place-selected d-none align-items-center gap-1 text-nowrap">
|
|
72
|
-
<i class="fa fa-map-marker" style="color:#1877F2;"></i>
|
|
73
|
-
<span class="fb-place-name fw-semibold"></span>
|
|
74
|
-
<a href="#" class="fb-place-clear ms-1 text-danger" title="Effacer">
|
|
75
|
-
<i class="fa fa-times"></i>
|
|
76
|
-
</a>
|
|
77
|
-
</span>
|
|
78
|
-
<input type="hidden" class="fb-place-id" value="">
|
|
79
|
-
` : ''}
|
|
80
|
-
</div>
|
|
81
|
-
`);
|
|
82
|
-
|
|
83
|
-
// ── Toggle actif/inactif ──
|
|
84
31
|
$btn.on('click', function () {
|
|
85
32
|
const active = !$(this).data('fbpost-active');
|
|
86
33
|
$(this).data('fbpost-active', active);
|
|
87
|
-
|
|
88
|
-
$(this).css({ color: '#1877F2', opacity: '1' });
|
|
89
|
-
$placePanel.removeClass('d-none').addClass('d-flex');
|
|
90
|
-
} else {
|
|
91
|
-
$(this).css({ color: '', opacity: '0.45' });
|
|
92
|
-
$placePanel.removeClass('d-flex').addClass('d-none');
|
|
93
|
-
if (showPlace) resetPlace($placePanel);
|
|
94
|
-
}
|
|
34
|
+
$(this).css({ color: active ? '#1877F2' : '', opacity: active ? '1' : '0.45' });
|
|
95
35
|
});
|
|
96
36
|
|
|
97
|
-
// ── Recherche de lieu avec debounce ──
|
|
98
|
-
if (showPlace) {
|
|
99
|
-
let debounceTimer;
|
|
100
|
-
|
|
101
|
-
$placePanel.find('.fb-place-search').on('input', function () {
|
|
102
|
-
const q = $(this).val().trim();
|
|
103
|
-
const $results = $placePanel.find('.fb-place-results');
|
|
104
|
-
clearTimeout(debounceTimer);
|
|
105
|
-
if (q.length < 2) { $results.hide().empty(); return; }
|
|
106
|
-
|
|
107
|
-
debounceTimer = setTimeout(async () => {
|
|
108
|
-
const places = await searchPlace(q);
|
|
109
|
-
$results.empty();
|
|
110
|
-
if (!places.length) { $results.hide(); return; }
|
|
111
|
-
|
|
112
|
-
places.forEach(p => {
|
|
113
|
-
const loc = p.location
|
|
114
|
-
? [p.location.city, p.location.country].filter(Boolean).join(', ')
|
|
115
|
-
: '';
|
|
116
|
-
$('<li>').append(
|
|
117
|
-
$('<a href="#" class="dropdown-item rounded-1 py-1">')
|
|
118
|
-
.attr({ 'data-place-id': p.id, 'data-place-name': p.name })
|
|
119
|
-
.append($('<div class="fw-semibold lh-sm">').text(p.name))
|
|
120
|
-
.append(loc ? $('<div class="text-muted small lh-sm">').text(loc) : null)
|
|
121
|
-
.on('click', function (e) {
|
|
122
|
-
e.preventDefault();
|
|
123
|
-
$placePanel.find('.fb-place-id').val($(this).data('place-id'));
|
|
124
|
-
$placePanel.find('.fb-place-name').text($(this).data('place-name'));
|
|
125
|
-
$placePanel.find('.fb-place-selected').removeClass('d-none').addClass('d-inline-flex');
|
|
126
|
-
$placePanel.find('.fb-place-search').addClass('d-none');
|
|
127
|
-
$results.hide().empty();
|
|
128
|
-
})
|
|
129
|
-
).appendTo($results);
|
|
130
|
-
});
|
|
131
|
-
$results.show();
|
|
132
|
-
}, DEBOUNCE_MS);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
$placePanel.find('.fb-place-clear').on('click', function (e) {
|
|
136
|
-
e.preventDefault();
|
|
137
|
-
$placePanel.find('.fb-place-id').val('');
|
|
138
|
-
$placePanel.find('.fb-place-selected').removeClass('d-inline-flex').addClass('d-none');
|
|
139
|
-
$placePanel.find('.fb-place-search').removeClass('d-none').val('').trigger('focus');
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// Fermer le dropdown si clic ailleurs
|
|
143
|
-
$(document).off('click.fbpost').on('click.fbpost', function (e) {
|
|
144
|
-
if (!$(e.target).closest('.fb-place-panel').length) {
|
|
145
|
-
$placePanel.find('.fb-place-results').hide().empty();
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ── Injection dans la toolbar ──
|
|
151
37
|
const $toolbar = $composer.find('ul.formatting-group').first();
|
|
152
38
|
if ($toolbar.length) {
|
|
153
39
|
$toolbar.append($li);
|
|
154
|
-
$toolbar.after($placePanel);
|
|
155
40
|
} else {
|
|
156
|
-
|
|
157
|
-
const $write = $composer.find('.write').first();
|
|
158
|
-
$write.before($placePanel);
|
|
159
|
-
$placePanel.before($li);
|
|
41
|
+
$composer.find('.write').first().before($li);
|
|
160
42
|
}
|
|
161
43
|
|
|
162
|
-
// ── Hook submit ──
|
|
163
44
|
$(window).off('action:composer.submit.fbpost')
|
|
164
|
-
.on('action:composer.submit.fbpost', function (
|
|
165
|
-
const enabled = !!$btn.data('fbpost-active');
|
|
45
|
+
.on('action:composer.submit.fbpost', function (ev, data) {
|
|
166
46
|
const postData = (data && data.composerData) || (data && data.postData) || data;
|
|
167
47
|
if (!postData || typeof postData !== 'object') return;
|
|
168
|
-
postData.fbPostEnabled =
|
|
169
|
-
if (enabled && showPlace) {
|
|
170
|
-
const placeId = $placePanel.find('.fb-place-id').val();
|
|
171
|
-
if (placeId) postData.fbPlaceId = placeId;
|
|
172
|
-
}
|
|
48
|
+
postData.fbPostEnabled = !!$btn.data('fbpost-active');
|
|
173
49
|
});
|
|
174
50
|
}
|
|
175
51
|
|
|
@@ -179,7 +55,7 @@
|
|
|
179
55
|
if (!$composer || !$composer.length) return;
|
|
180
56
|
const perm = await canPost();
|
|
181
57
|
if (!perm.allowed) return;
|
|
182
|
-
injectUI($composer
|
|
58
|
+
injectUI($composer);
|
|
183
59
|
} catch {
|
|
184
60
|
// ignore
|
|
185
61
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<div class="acp-page-container">
|
|
2
|
-
<h2>Facebook
|
|
2
|
+
<h2>Facebook Post</h2>
|
|
3
3
|
|
|
4
4
|
<form class="facebook-post-settings" role="form">
|
|
5
5
|
<div class="form-check mb-3">
|
|
@@ -7,15 +7,10 @@
|
|
|
7
7
|
<label class="form-check-label" for="enabled">Activer</label>
|
|
8
8
|
</div>
|
|
9
9
|
|
|
10
|
-
<div class="form-check mb-3">
|
|
11
|
-
<input type="checkbox" class="form-check-input" id="publishInstagram" name="publishInstagram">
|
|
12
|
-
<label class="form-check-label" for="publishInstagram">Publier aussi sur Instagram (uniquement si une image est présente)</label>
|
|
13
|
-
</div>
|
|
14
|
-
|
|
15
10
|
<hr/>
|
|
16
11
|
|
|
17
12
|
<h4>Accès</h4>
|
|
18
|
-
<p class="text-muted">Sélectionne les groupes autorisés à voir la case
|
|
13
|
+
<p class="text-muted">Sélectionne les groupes autorisés à voir la case "Publier sur Facebook" dans le composer.</p>
|
|
19
14
|
|
|
20
15
|
<div class="mb-3">
|
|
21
16
|
<label class="form-label" for="allowedGroupsSelect">Groupes autorisés</label>
|
|
@@ -25,7 +20,7 @@
|
|
|
25
20
|
<!-- END allGroups -->
|
|
26
21
|
</select>
|
|
27
22
|
<input type="hidden" id="allowedGroups" name="allowedGroups" value="">
|
|
28
|
-
<p class="form-text">Sans fallback : si aucun groupe n
|
|
23
|
+
<p class="form-text">Sans fallback : si aucun groupe n'est sélectionné, personne ne verra la case.</p>
|
|
29
24
|
</div>
|
|
30
25
|
|
|
31
26
|
<hr/>
|
|
@@ -38,7 +33,7 @@
|
|
|
38
33
|
</div>
|
|
39
34
|
|
|
40
35
|
<div class="mb-3">
|
|
41
|
-
<label class="form-label" for="excerptMaxLen">Longueur max de l
|
|
36
|
+
<label class="form-label" for="excerptMaxLen">Longueur max de l'extrait</label>
|
|
42
37
|
<input type="number" class="form-control" id="excerptMaxLen" name="excerptMaxLen" min="50" max="5000">
|
|
43
38
|
</div>
|
|
44
39
|
|
|
@@ -62,19 +57,10 @@
|
|
|
62
57
|
<h4>Images</h4>
|
|
63
58
|
|
|
64
59
|
<div class="mb-3">
|
|
65
|
-
<label class="form-label" for="maxImages">Nombre max d
|
|
60
|
+
<label class="form-label" for="maxImages">Nombre max d'images (uploads du forum)</label>
|
|
66
61
|
<input type="number" class="form-control" id="maxImages" name="maxImages" min="0" max="20">
|
|
67
62
|
</div>
|
|
68
63
|
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
<h4>Lieu / Géolocalisation</h4>
|
|
72
|
-
|
|
73
|
-
<div class="form-check mb-3">
|
|
74
|
-
<input type="checkbox" class="form-check-input" id="enablePlaceTagging" name="enablePlaceTagging">
|
|
75
|
-
<label class="form-check-label" for="enablePlaceTagging">Activer le tag de lieu (Place ID / location_id)</label>
|
|
76
|
-
</div>
|
|
77
|
-
|
|
78
|
-
<button type="button" id="save" class="btn btn-primary">Enregistrer</button>
|
|
64
|
+
<button type="button" id="save" class="btn btn-primary mt-2">Enregistrer</button>
|
|
79
65
|
</form>
|
|
80
66
|
</div>
|