nodebb-plugin-calendar-onekite 11.2.38 → 12.0.1
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/CHANGELOG.md +7 -4
- package/lib/api.js +141 -17
- package/library.js +2 -0
- package/package.json +1 -1
- package/plugin.json +2 -1
- package/public/admin.js +3 -24
- package/public/client.js +141 -19
- package/templates/admin/plugins/calendar-onekite.tpl +5 -8
- package/templates/calendar-onekite.tpl +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
## 1.1.0
|
|
2
|
+
### Perf / prod (NodeBB v4)
|
|
3
|
+
- FullCalendar : passage au CDN **@latest** et utilisation de `main.min.css` (supprime l’erreur 404 `index.global.min.css`).
|
|
4
|
+
- API `events` : payload allégé (les détails sont chargés à la demande), tri stable.
|
|
5
|
+
- Cache intelligent : support **ETag** côté serveur + requêtes conditionnelles côté client (réduit les transferts lors des refetch).
|
|
6
|
+
- Prefetch : préchargement du mois précédent/suivant (si l’utilisateur navigue, la vue est instantanée).
|
|
7
|
+
- Robustesse : anti double-refetch (debounce sur les updates socket) et annulation des fetch concurrents.
|
|
1
8
|
|
|
2
|
-
## 1.0.2
|
|
3
|
-
- Front : empêche la double initialisation du calendrier (évite le clignotement au chargement / après actions).
|
|
4
|
-
- ACP (NodeBB v4) : init robuste via `hooks.on('action:ajaxify.end')` + garde-fou anti double-bind (évite les multi-popups).
|
|
5
|
-
- Maintenance : suppression du mapping module ACP legacy (`../admin/plugins/...`).
|
|
6
9
|
|
|
7
10
|
## 1.0.1.1
|
|
8
11
|
- ACP (NodeBB v4) : empêche l’affichage multiple des popups de succès lors de l’enregistrement (déduplication des alerts).
|
package/lib/api.js
CHANGED
|
@@ -252,6 +252,12 @@ function eventsForSpecial(ev) {
|
|
|
252
252
|
|
|
253
253
|
const api = {};
|
|
254
254
|
|
|
255
|
+
function computeEtag(payload) {
|
|
256
|
+
// Weak ETag is fine here: it is only used to skip identical JSON payloads.
|
|
257
|
+
const hash = crypto.createHash('sha1').update(JSON.stringify(payload)).digest('hex');
|
|
258
|
+
return `W/"${hash}"`;
|
|
259
|
+
}
|
|
260
|
+
|
|
255
261
|
api.getEvents = async function (req, res) {
|
|
256
262
|
const startTs = toTs(req.query.start) || 0;
|
|
257
263
|
const endTs = toTs(req.query.end) || (Date.now() + 365 * 24 * 3600 * 1000);
|
|
@@ -276,22 +282,35 @@ api.getEvents = async function (req, res) {
|
|
|
276
282
|
if (!(rStart < endTs && startTs < rEnd)) continue; // overlap check
|
|
277
283
|
const evs = eventsFor(r);
|
|
278
284
|
for (const ev of evs) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
285
|
+
const p = ev.extendedProps || {};
|
|
286
|
+
const minimal = {
|
|
287
|
+
id: ev.id,
|
|
288
|
+
title: ev.title,
|
|
289
|
+
backgroundColor: ev.backgroundColor,
|
|
290
|
+
borderColor: ev.borderColor,
|
|
291
|
+
textColor: ev.textColor,
|
|
292
|
+
allDay: ev.allDay,
|
|
293
|
+
start: ev.start,
|
|
294
|
+
end: ev.end,
|
|
295
|
+
extendedProps: {
|
|
296
|
+
type: 'reservation',
|
|
297
|
+
rid: p.rid,
|
|
298
|
+
status: p.status,
|
|
299
|
+
uid: p.uid,
|
|
300
|
+
canModerate: canMod,
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
// Only expose username on the event list to owner/moderators.
|
|
283
304
|
if (r.username && ((req.uid && String(req.uid) === String(r.uid)) || canMod)) {
|
|
284
|
-
|
|
305
|
+
minimal.extendedProps.username = String(r.username);
|
|
285
306
|
}
|
|
286
|
-
|
|
287
|
-
ev.extendedProps.createdAt = r.createdAt || null;
|
|
288
|
-
// Expose payment URL only to the requester (or moderators) when awaiting payment
|
|
307
|
+
// Let the UI decide if a "Payer" button might exist, without exposing the URL in list.
|
|
289
308
|
if (r.status === 'awaiting_payment' && r.paymentUrl && (/^https?:\/\//i).test(String(r.paymentUrl))) {
|
|
290
309
|
if ((req.uid && String(req.uid) === String(r.uid)) || canMod) {
|
|
291
|
-
|
|
310
|
+
minimal.extendedProps.hasPayment = true;
|
|
292
311
|
}
|
|
293
312
|
}
|
|
294
|
-
out.push(
|
|
313
|
+
out.push(minimal);
|
|
295
314
|
}
|
|
296
315
|
}
|
|
297
316
|
|
|
@@ -304,19 +323,124 @@ api.getEvents = async function (req, res) {
|
|
|
304
323
|
const sStart = parseInt(sev.start, 10);
|
|
305
324
|
const sEnd = parseInt(sev.end, 10);
|
|
306
325
|
if (!(sStart < endTs && startTs < sEnd)) continue;
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
326
|
+
const full = eventsForSpecial(sev);
|
|
327
|
+
const minimal = {
|
|
328
|
+
id: full.id,
|
|
329
|
+
title: full.title,
|
|
330
|
+
allDay: full.allDay,
|
|
331
|
+
start: full.start,
|
|
332
|
+
end: full.end,
|
|
333
|
+
backgroundColor: full.backgroundColor,
|
|
334
|
+
borderColor: full.borderColor,
|
|
335
|
+
textColor: full.textColor,
|
|
336
|
+
extendedProps: {
|
|
337
|
+
type: 'special',
|
|
338
|
+
eid: sev.eid,
|
|
339
|
+
canCreateSpecial: canSpecialCreate,
|
|
340
|
+
canDeleteSpecial: canSpecialDelete,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
311
343
|
if (sev.username && (canMod || canSpecialDelete || (req.uid && String(req.uid) === String(sev.uid)))) {
|
|
312
|
-
|
|
344
|
+
minimal.extendedProps.username = String(sev.username);
|
|
313
345
|
}
|
|
314
|
-
out.push(
|
|
346
|
+
out.push(minimal);
|
|
315
347
|
}
|
|
316
348
|
} catch (e) {
|
|
317
349
|
// ignore
|
|
318
350
|
}
|
|
319
|
-
|
|
351
|
+
|
|
352
|
+
// Stable ordering -> stable ETag
|
|
353
|
+
out.sort((a, b) => {
|
|
354
|
+
const as = String(a.start || '');
|
|
355
|
+
const bs = String(b.start || '');
|
|
356
|
+
if (as !== bs) return as < bs ? -1 : 1;
|
|
357
|
+
const ai = String(a.id || '');
|
|
358
|
+
const bi = String(b.id || '');
|
|
359
|
+
return ai < bi ? -1 : ai > bi ? 1 : 0;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const etag = computeEtag(out);
|
|
363
|
+
res.setHeader('ETag', etag);
|
|
364
|
+
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
365
|
+
if (String(req.headers['if-none-match'] || '') === etag) {
|
|
366
|
+
return res.status(304).end();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return res.json(out);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
api.getReservationDetails = async function (req, res) {
|
|
373
|
+
const uid = req.uid;
|
|
374
|
+
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
375
|
+
|
|
376
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
377
|
+
const canMod = await canValidate(uid, settings);
|
|
378
|
+
|
|
379
|
+
const rid = String(req.params.rid || '').trim();
|
|
380
|
+
if (!rid) return res.status(400).json({ error: 'missing-rid' });
|
|
381
|
+
const r = await dbLayer.getReservation(rid);
|
|
382
|
+
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
383
|
+
|
|
384
|
+
const isOwner = String(r.uid) === String(uid);
|
|
385
|
+
if (!isOwner && !canMod) return res.status(403).json({ error: 'not-allowed' });
|
|
386
|
+
|
|
387
|
+
const out = {
|
|
388
|
+
rid: r.rid,
|
|
389
|
+
status: r.status,
|
|
390
|
+
uid: r.uid,
|
|
391
|
+
username: r.username || '',
|
|
392
|
+
itemNames: Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []),
|
|
393
|
+
itemIds: Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : []),
|
|
394
|
+
start: r.start,
|
|
395
|
+
end: r.end,
|
|
396
|
+
approvedByUsername: r.approvedByUsername || '',
|
|
397
|
+
pickupAddress: r.pickupAddress || '',
|
|
398
|
+
pickupTime: r.pickupTime || '',
|
|
399
|
+
pickupLat: r.pickupLat || '',
|
|
400
|
+
pickupLon: r.pickupLon || '',
|
|
401
|
+
notes: r.notes || '',
|
|
402
|
+
refusedReason: r.refusedReason || '',
|
|
403
|
+
total: r.total || 0,
|
|
404
|
+
canModerate: canMod,
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
if (r.status === 'awaiting_payment' && r.paymentUrl && (/^https?:\/\//i).test(String(r.paymentUrl))) {
|
|
408
|
+
out.paymentUrl = String(r.paymentUrl);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return res.json(out);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
api.getSpecialEventDetails = async function (req, res) {
|
|
415
|
+
const uid = req.uid;
|
|
416
|
+
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
417
|
+
|
|
418
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
419
|
+
const canMod = await canValidate(uid, settings);
|
|
420
|
+
const canSpecialDelete = await canDeleteSpecial(uid, settings);
|
|
421
|
+
|
|
422
|
+
const eid = String(req.params.eid || '').trim();
|
|
423
|
+
if (!eid) return res.status(400).json({ error: 'missing-eid' });
|
|
424
|
+
const ev = await dbLayer.getSpecialEvent(eid);
|
|
425
|
+
if (!ev) return res.status(404).json({ error: 'not-found' });
|
|
426
|
+
|
|
427
|
+
// Anyone who can see the calendar can view special events, but creator username
|
|
428
|
+
// is only visible to moderators/allowed users or the creator.
|
|
429
|
+
const out = {
|
|
430
|
+
eid: ev.eid,
|
|
431
|
+
title: ev.title || '',
|
|
432
|
+
start: ev.start,
|
|
433
|
+
end: ev.end,
|
|
434
|
+
address: ev.address || '',
|
|
435
|
+
lat: ev.lat || '',
|
|
436
|
+
lon: ev.lon || '',
|
|
437
|
+
notes: ev.notes || '',
|
|
438
|
+
canDeleteSpecial: canSpecialDelete,
|
|
439
|
+
};
|
|
440
|
+
if (ev.username && (canMod || canSpecialDelete || (uid && String(uid) === String(ev.uid)))) {
|
|
441
|
+
out.username = String(ev.username);
|
|
442
|
+
}
|
|
443
|
+
return res.json(out);
|
|
320
444
|
};
|
|
321
445
|
|
|
322
446
|
api.getCapabilities = async function (req, res) {
|
package/library.js
CHANGED
|
@@ -66,11 +66,13 @@ Plugin.init = async function (params) {
|
|
|
66
66
|
router.get('/api/v3/plugins/calendar-onekite/capabilities', ...publicExpose, api.getCapabilities);
|
|
67
67
|
|
|
68
68
|
router.post('/api/v3/plugins/calendar-onekite/reservations', ...publicExpose, api.createReservation);
|
|
69
|
+
router.get('/api/v3/plugins/calendar-onekite/reservations/:rid', ...publicExpose, api.getReservationDetails);
|
|
69
70
|
router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/approve', ...publicExpose, api.approveReservation);
|
|
70
71
|
router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/refuse', ...publicExpose, api.refuseReservation);
|
|
71
72
|
router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/cancel', ...publicExpose, api.cancelReservation);
|
|
72
73
|
|
|
73
74
|
router.post('/api/v3/plugins/calendar-onekite/special-events', ...publicExpose, api.createSpecialEvent);
|
|
75
|
+
router.get('/api/v3/plugins/calendar-onekite/special-events/:eid', ...publicExpose, api.getSpecialEventDetails);
|
|
74
76
|
router.delete('/api/v3/plugins/calendar-onekite/special-events/:eid', ...publicExpose, api.deleteSpecialEvent);
|
|
75
77
|
|
|
76
78
|
// Admin API (JSON)
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"templates": "./templates",
|
|
24
24
|
"modules": {
|
|
25
|
+
"../admin/plugins/calendar-onekite.js": "./public/admin.js",
|
|
25
26
|
"admin/plugins/calendar-onekite": "./public/admin.js"
|
|
26
27
|
},
|
|
27
28
|
"scripts": [
|
|
@@ -30,5 +31,5 @@
|
|
|
30
31
|
"acpScripts": [
|
|
31
32
|
"public/admin.js"
|
|
32
33
|
],
|
|
33
|
-
"version": "1.0.
|
|
34
|
+
"version": "1.0.1.1"
|
|
34
35
|
}
|
package/public/admin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'
|
|
2
|
+
define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts, bootbox) {
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
5
|
// Cache of pending reservations keyed by rid so delegated click handlers
|
|
@@ -91,7 +91,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox', 'hooks'], functio
|
|
|
91
91
|
const link = document.createElement('link');
|
|
92
92
|
link.id = cssId;
|
|
93
93
|
link.rel = 'stylesheet';
|
|
94
|
-
link.href = 'https://
|
|
94
|
+
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
|
95
95
|
document.head.appendChild(link);
|
|
96
96
|
}
|
|
97
97
|
const existing = document.getElementById(jsId);
|
|
@@ -103,7 +103,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox', 'hooks'], functio
|
|
|
103
103
|
const script = document.createElement('script');
|
|
104
104
|
script.id = jsId;
|
|
105
105
|
script.async = true;
|
|
106
|
-
script.src = 'https://
|
|
106
|
+
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
|
107
107
|
script.onload = () => resolve(window.L);
|
|
108
108
|
script.onerror = () => reject(new Error('leaflet-load-failed'));
|
|
109
109
|
document.head.appendChild(script);
|
|
@@ -335,12 +335,6 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox', 'hooks'], functio
|
|
|
335
335
|
const form = document.getElementById('onekite-settings-form');
|
|
336
336
|
if (!form) return;
|
|
337
337
|
|
|
338
|
-
// Avoid duplicate listeners/toasts when revisiting the ACP page via ajaxify.
|
|
339
|
-
if (form.getAttribute('data-onekite-bound') === '1') {
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
form.setAttribute('data-onekite-bound', '1');
|
|
343
|
-
|
|
344
338
|
// Make the HelloAsso debug output readable in both light and dark ACP themes.
|
|
345
339
|
// NodeBB 4.x uses Bootstrap variables, so we can rely on CSS variables here.
|
|
346
340
|
(function injectAdminCss() {
|
|
@@ -807,20 +801,5 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox', 'hooks'], functio
|
|
|
807
801
|
}
|
|
808
802
|
}
|
|
809
803
|
|
|
810
|
-
// Auto-init when navigating in ACP via ajaxify.
|
|
811
|
-
try {
|
|
812
|
-
const autoInit = function (data) {
|
|
813
|
-
const tpl = data && data.template ? data.template.name : (window.ajaxify && ajaxify.data && ajaxify.data.template ? ajaxify.data.template.name : '');
|
|
814
|
-
if (tpl === 'admin/plugins/calendar-onekite') {
|
|
815
|
-
init();
|
|
816
|
-
}
|
|
817
|
-
};
|
|
818
|
-
if (hooks && typeof hooks.on === 'function') {
|
|
819
|
-
hooks.on('action:ajaxify.end', autoInit);
|
|
820
|
-
}
|
|
821
|
-
// Also try once for the initial render.
|
|
822
|
-
setTimeout(() => autoInit({ template: (window.ajaxify && ajaxify.data && ajaxify.data.template) || { name: '' } }), 0);
|
|
823
|
-
} catch (e) {}
|
|
824
|
-
|
|
825
804
|
return { init };
|
|
826
805
|
});
|
package/public/client.js
CHANGED
|
@@ -434,6 +434,69 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
434
434
|
return await res.json();
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
// Simple in-memory cache for JSON endpoints (used for events prefetch + ETag).
|
|
438
|
+
const jsonCache = new Map(); // url -> { etag, data, ts }
|
|
439
|
+
|
|
440
|
+
function scheduleRefetch(cal) {
|
|
441
|
+
try {
|
|
442
|
+
if (!cal || typeof cal.refetchEvents !== 'function') return;
|
|
443
|
+
clearTimeout(window.__onekiteRefetchTimer);
|
|
444
|
+
window.__onekiteRefetchTimer = setTimeout(() => {
|
|
445
|
+
try { cal.refetchEvents(); } catch (e) {}
|
|
446
|
+
}, 150);
|
|
447
|
+
} catch (e) {}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function fetchJsonCached(url, opts) {
|
|
451
|
+
const cached = jsonCache.get(url);
|
|
452
|
+
const headers = Object.assign({}, (opts && opts.headers) || {});
|
|
453
|
+
if (cached && cached.etag) {
|
|
454
|
+
headers['If-None-Match'] = cached.etag;
|
|
455
|
+
}
|
|
456
|
+
let res;
|
|
457
|
+
try {
|
|
458
|
+
res = await fetch(url, {
|
|
459
|
+
credentials: 'same-origin',
|
|
460
|
+
headers: (() => {
|
|
461
|
+
// reuse csrf header builder (fetchJson) by calling it indirectly
|
|
462
|
+
const base = { 'Content-Type': 'application/json' };
|
|
463
|
+
const token =
|
|
464
|
+
(window.config && (window.config.csrf_token || window.config.csrfToken)) ||
|
|
465
|
+
(window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
|
|
466
|
+
(document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
|
|
467
|
+
(document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
|
|
468
|
+
(typeof app !== 'undefined' && app && app.csrfToken) ||
|
|
469
|
+
null;
|
|
470
|
+
if (token) base['x-csrf-token'] = token;
|
|
471
|
+
return Object.assign(base, headers);
|
|
472
|
+
})(),
|
|
473
|
+
...opts,
|
|
474
|
+
});
|
|
475
|
+
} catch (e) {
|
|
476
|
+
// If offline and we have cache, use it.
|
|
477
|
+
if (cached && cached.data) return cached.data;
|
|
478
|
+
throw e;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (res.status === 304 && cached && cached.data) {
|
|
482
|
+
return cached.data;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!res.ok) {
|
|
486
|
+
let payload = null;
|
|
487
|
+
try { payload = await res.json(); } catch (e) {}
|
|
488
|
+
const err = new Error(`${res.status}`);
|
|
489
|
+
err.status = res.status;
|
|
490
|
+
err.payload = payload;
|
|
491
|
+
throw err;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const data = await res.json();
|
|
495
|
+
const etag = res.headers.get('ETag') || '';
|
|
496
|
+
jsonCache.set(url, { etag, data, ts: Date.now() });
|
|
497
|
+
return data;
|
|
498
|
+
}
|
|
499
|
+
|
|
437
500
|
async function loadCapabilities() {
|
|
438
501
|
return await fetchJson('/api/v3/plugins/calendar-onekite/capabilities');
|
|
439
502
|
}
|
|
@@ -456,7 +519,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
456
519
|
const link = document.createElement('link');
|
|
457
520
|
link.id = cssId;
|
|
458
521
|
link.rel = 'stylesheet';
|
|
459
|
-
link.href = 'https://
|
|
522
|
+
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
|
460
523
|
document.head.appendChild(link);
|
|
461
524
|
}
|
|
462
525
|
|
|
@@ -470,7 +533,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
470
533
|
const script = document.createElement('script');
|
|
471
534
|
script.id = jsId;
|
|
472
535
|
script.async = true;
|
|
473
|
-
script.src = 'https://
|
|
536
|
+
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
|
474
537
|
script.onload = () => resolve(window.L);
|
|
475
538
|
script.onerror = () => reject(new Error('leaflet-load-failed'));
|
|
476
539
|
document.head.appendChild(script);
|
|
@@ -828,12 +891,6 @@ function toDatetimeLocalValue(date) {
|
|
|
828
891
|
return;
|
|
829
892
|
}
|
|
830
893
|
|
|
831
|
-
// Avoid double init (ajaxify + initial load tick).
|
|
832
|
-
if (el.getAttribute('data-onekite-initialised') === '1') {
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
el.setAttribute('data-onekite-initialised', '1');
|
|
836
|
-
|
|
837
894
|
if (typeof FullCalendar === 'undefined') {
|
|
838
895
|
showAlert('error', 'FullCalendar non chargé');
|
|
839
896
|
return;
|
|
@@ -883,14 +940,28 @@ function toDatetimeLocalValue(date) {
|
|
|
883
940
|
try {
|
|
884
941
|
const btn = document.querySelector('#onekite-calendar .fc-newSpecial-button');
|
|
885
942
|
if (!btn) return;
|
|
943
|
+
|
|
886
944
|
const isSpecial = mode === 'special';
|
|
887
945
|
const label = isSpecial ? 'Évènement ✓' : 'Évènement';
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
946
|
+
|
|
947
|
+
// Ensure a single canonical .fc-button-text (prevents "ÉvènementÉvènement" after rerenders)
|
|
948
|
+
let span = btn.querySelector('.fc-button-text');
|
|
949
|
+
if (!span) {
|
|
950
|
+
span = document.createElement('span');
|
|
951
|
+
span.className = 'fc-button-text';
|
|
952
|
+
// Remove stray text nodes before inserting span
|
|
953
|
+
[...btn.childNodes].forEach((n) => {
|
|
954
|
+
if (n && n.nodeType === Node.TEXT_NODE) n.remove();
|
|
955
|
+
});
|
|
956
|
+
btn.appendChild(span);
|
|
891
957
|
} else {
|
|
892
|
-
|
|
958
|
+
// Remove any stray text nodes beside the span
|
|
959
|
+
[...btn.childNodes].forEach((n) => {
|
|
960
|
+
if (n && n.nodeType === Node.TEXT_NODE && n.textContent.trim()) n.remove();
|
|
961
|
+
});
|
|
893
962
|
}
|
|
963
|
+
|
|
964
|
+
span.textContent = label;
|
|
894
965
|
btn.classList.toggle('onekite-active', isSpecial);
|
|
895
966
|
} catch (e) {}
|
|
896
967
|
}
|
|
@@ -1015,8 +1086,32 @@ function toDatetimeLocalValue(date) {
|
|
|
1015
1086
|
selectMirror: true,
|
|
1016
1087
|
events: async function (info, successCallback, failureCallback) {
|
|
1017
1088
|
try {
|
|
1089
|
+
// Abort previous in-flight events fetch to avoid "double refresh" effects.
|
|
1090
|
+
if (window.__onekiteEventsAbort) {
|
|
1091
|
+
try { window.__onekiteEventsAbort.abort(); } catch (e) {}
|
|
1092
|
+
}
|
|
1093
|
+
const abort = new AbortController();
|
|
1094
|
+
window.__onekiteEventsAbort = abort;
|
|
1095
|
+
|
|
1018
1096
|
const qs = new URLSearchParams({ start: info.startStr, end: info.endStr });
|
|
1019
|
-
const
|
|
1097
|
+
const url = `/api/v3/plugins/calendar-onekite/events?${qs.toString()}`;
|
|
1098
|
+
const data = await fetchJsonCached(url, { signal: abort.signal });
|
|
1099
|
+
|
|
1100
|
+
// Prefetch adjacent range (previous/next) for snappier navigation.
|
|
1101
|
+
try {
|
|
1102
|
+
const spanMs = (info.end && info.start) ? (info.end.getTime() - info.start.getTime()) : 0;
|
|
1103
|
+
if (spanMs > 0 && spanMs < 1000 * 3600 * 24 * 120) {
|
|
1104
|
+
const prevStart = new Date(info.start.getTime() - spanMs);
|
|
1105
|
+
const prevEnd = new Date(info.start.getTime());
|
|
1106
|
+
const nextStart = new Date(info.end.getTime());
|
|
1107
|
+
const nextEnd = new Date(info.end.getTime() + spanMs);
|
|
1108
|
+
const toStr = (d) => new Date(d.getTime()).toISOString();
|
|
1109
|
+
const qPrev = new URLSearchParams({ start: toStr(prevStart), end: toStr(prevEnd) });
|
|
1110
|
+
const qNext = new URLSearchParams({ start: toStr(nextStart), end: toStr(nextEnd) });
|
|
1111
|
+
fetchJsonCached(`/api/v3/plugins/calendar-onekite/events?${qPrev.toString()}`).catch(() => {});
|
|
1112
|
+
fetchJsonCached(`/api/v3/plugins/calendar-onekite/events?${qNext.toString()}`).catch(() => {});
|
|
1113
|
+
}
|
|
1114
|
+
} catch (e) {}
|
|
1020
1115
|
|
|
1021
1116
|
// IMPORTANT: align "special" event display exactly like reservation icons.
|
|
1022
1117
|
// We inject the clock + time range directly into the event title so FC
|
|
@@ -1192,9 +1287,33 @@ function toDatetimeLocalValue(date) {
|
|
|
1192
1287
|
},
|
|
1193
1288
|
|
|
1194
1289
|
eventClick: async function (info) {
|
|
1290
|
+
if (isDialogOpen) return;
|
|
1291
|
+
isDialogOpen = true;
|
|
1195
1292
|
const ev = info.event;
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1293
|
+
const p0 = ev.extendedProps || {};
|
|
1294
|
+
|
|
1295
|
+
// Load full details lazily (events list is lightweight for perf).
|
|
1296
|
+
let p = p0;
|
|
1297
|
+
try {
|
|
1298
|
+
if (p0.type === 'reservation' && p0.rid) {
|
|
1299
|
+
const details = await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${encodeURIComponent(String(p0.rid))}`);
|
|
1300
|
+
p = Object.assign({}, p0, details);
|
|
1301
|
+
} else if (p0.type === 'special' && p0.eid) {
|
|
1302
|
+
const details = await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(String(p0.eid))}`);
|
|
1303
|
+
p = Object.assign({}, p0, details, {
|
|
1304
|
+
// keep backward compat with older field names used by templates below
|
|
1305
|
+
pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
|
|
1306
|
+
pickupLat: details.lat || details.pickupLat || p0.pickupLat,
|
|
1307
|
+
pickupLon: details.lon || details.pickupLon || p0.pickupLon,
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
} catch (e) {
|
|
1311
|
+
// ignore detail fetch errors; fall back to minimal props
|
|
1312
|
+
p = p0;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
try {
|
|
1316
|
+
if (p.type === 'special') {
|
|
1198
1317
|
const username = String(p.username || '').trim();
|
|
1199
1318
|
const userLine = username
|
|
1200
1319
|
? `<div class="mb-2"><strong>Créé par</strong><br><a class="onekite-user-link" href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
|
|
@@ -1541,6 +1660,9 @@ function toDatetimeLocalValue(date) {
|
|
|
1541
1660
|
message: baseHtml,
|
|
1542
1661
|
buttons,
|
|
1543
1662
|
});
|
|
1663
|
+
} finally {
|
|
1664
|
+
isDialogOpen = false;
|
|
1665
|
+
}
|
|
1544
1666
|
},
|
|
1545
1667
|
});
|
|
1546
1668
|
|
|
@@ -1634,6 +1756,8 @@ function toDatetimeLocalValue(date) {
|
|
|
1634
1756
|
calendar.setOption('titleFormat', mobile ? { year: 'numeric', month: 'short' } : undefined);
|
|
1635
1757
|
}
|
|
1636
1758
|
try { calendar.updateSize(); } catch (err) {}
|
|
1759
|
+
|
|
1760
|
+
try { refreshDesktopModeButton(); } catch (e) {}
|
|
1637
1761
|
});
|
|
1638
1762
|
} catch (e) {}
|
|
1639
1763
|
}
|
|
@@ -1662,12 +1786,10 @@ function toDatetimeLocalValue(date) {
|
|
|
1662
1786
|
try {
|
|
1663
1787
|
if (!window.__oneKiteSocketBound && typeof socket !== 'undefined' && socket && typeof socket.on === 'function') {
|
|
1664
1788
|
window.__oneKiteSocketBound = true;
|
|
1665
|
-
socket.on('event:calendar-onekite.reservationUpdated', function (
|
|
1789
|
+
socket.on('event:calendar-onekite.reservationUpdated', function () {
|
|
1666
1790
|
try {
|
|
1667
1791
|
const cal = window.oneKiteCalendar;
|
|
1668
|
-
|
|
1669
|
-
cal.refetchEvents();
|
|
1670
|
-
}
|
|
1792
|
+
scheduleRefetch(cal);
|
|
1671
1793
|
} catch (e) {}
|
|
1672
1794
|
});
|
|
1673
1795
|
}
|
|
@@ -180,14 +180,11 @@
|
|
|
180
180
|
</div>
|
|
181
181
|
|
|
182
182
|
<script>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
}
|
|
183
|
+
require(['admin/plugins/calendar-onekite'], function (mod) {
|
|
184
|
+
if (mod && mod.init) {
|
|
185
|
+
mod.init();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
191
188
|
</script>
|
|
192
189
|
|
|
193
190
|
<!-- IMPORT admin/partials/settings/footer.tpl -->
|
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
</div>
|
|
8
8
|
</div>
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
<!--
|
|
11
|
+
FullCalendar
|
|
12
|
+
- Use CDN "latest" so NodeBB v4 stays simple to maintain.
|
|
13
|
+
- Use main.min.css (the global bundle does not ship index.global.min.css).
|
|
14
|
+
-->
|
|
15
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@latest/main.min.css" />
|
|
11
16
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@latest/index.global.min.js"></script>
|
|
12
|
-
<script src="https://cdn.jsdelivr.net/npm/
|
|
17
|
+
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@latest/locales-all.global.min.js"></script>
|
|
13
18
|
|
|
14
19
|
<!--
|
|
15
20
|
No inline require() here.
|