nodebb-plugin-calendar-onekite 11.2.32 → 12.0.0

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 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
- ev.extendedProps = ev.extendedProps || {};
280
- ev.extendedProps.canModerate = canMod;
281
- ev.extendedProps.total = r.total || 0;
282
- // Expose reserver username to owner/moderators for popup display
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
- ev.extendedProps.username = String(r.username);
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
- ev.extendedProps.paymentUrl = String(r.paymentUrl);
310
+ minimal.extendedProps.hasPayment = true;
292
311
  }
293
312
  }
294
- out.push(ev);
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 ev = eventsForSpecial(sev);
308
- ev.extendedProps.canCreateSpecial = canSpecialCreate;
309
- ev.extendedProps.canDeleteSpecial = canSpecialDelete;
310
- // Show creator username only to moderators/allowed users
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
- ev.extendedProps.username = String(sev.username);
344
+ minimal.extendedProps.username = String(sev.username);
313
345
  }
314
- out.push(ev);
346
+ out.push(minimal);
315
347
  }
316
348
  } catch (e) {
317
349
  // ignore
318
350
  }
319
- res.json(out);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "11.2.32",
3
+ "version": "12.0.0",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
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.2"
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', 'hooks'], function (alerts, bootbox, hooks) {
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
@@ -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
  }
@@ -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;
@@ -1015,8 +1072,32 @@ function toDatetimeLocalValue(date) {
1015
1072
  selectMirror: true,
1016
1073
  events: async function (info, successCallback, failureCallback) {
1017
1074
  try {
1075
+ // Abort previous in-flight events fetch to avoid "double refresh" effects.
1076
+ if (window.__onekiteEventsAbort) {
1077
+ try { window.__onekiteEventsAbort.abort(); } catch (e) {}
1078
+ }
1079
+ const abort = new AbortController();
1080
+ window.__onekiteEventsAbort = abort;
1081
+
1018
1082
  const qs = new URLSearchParams({ start: info.startStr, end: info.endStr });
1019
- const data = await fetchJson(`/api/v3/plugins/calendar-onekite/events?${qs.toString()}`);
1083
+ const url = `/api/v3/plugins/calendar-onekite/events?${qs.toString()}`;
1084
+ const data = await fetchJsonCached(url, { signal: abort.signal });
1085
+
1086
+ // Prefetch adjacent range (previous/next) for snappier navigation.
1087
+ try {
1088
+ const spanMs = (info.end && info.start) ? (info.end.getTime() - info.start.getTime()) : 0;
1089
+ if (spanMs > 0 && spanMs < 1000 * 3600 * 24 * 120) {
1090
+ const prevStart = new Date(info.start.getTime() - spanMs);
1091
+ const prevEnd = new Date(info.start.getTime());
1092
+ const nextStart = new Date(info.end.getTime());
1093
+ const nextEnd = new Date(info.end.getTime() + spanMs);
1094
+ const toStr = (d) => new Date(d.getTime()).toISOString();
1095
+ const qPrev = new URLSearchParams({ start: toStr(prevStart), end: toStr(prevEnd) });
1096
+ const qNext = new URLSearchParams({ start: toStr(nextStart), end: toStr(nextEnd) });
1097
+ fetchJsonCached(`/api/v3/plugins/calendar-onekite/events?${qPrev.toString()}`).catch(() => {});
1098
+ fetchJsonCached(`/api/v3/plugins/calendar-onekite/events?${qNext.toString()}`).catch(() => {});
1099
+ }
1100
+ } catch (e) {}
1020
1101
 
1021
1102
  // IMPORTANT: align "special" event display exactly like reservation icons.
1022
1103
  // We inject the clock + time range directly into the event title so FC
@@ -1192,9 +1273,33 @@ function toDatetimeLocalValue(date) {
1192
1273
  },
1193
1274
 
1194
1275
  eventClick: async function (info) {
1276
+ if (isDialogOpen) return;
1277
+ isDialogOpen = true;
1195
1278
  const ev = info.event;
1196
- const p = ev.extendedProps || {};
1197
- if (p.type === 'special') {
1279
+ const p0 = ev.extendedProps || {};
1280
+
1281
+ // Load full details lazily (events list is lightweight for perf).
1282
+ let p = p0;
1283
+ try {
1284
+ if (p0.type === 'reservation' && p0.rid) {
1285
+ const details = await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${encodeURIComponent(String(p0.rid))}`);
1286
+ p = Object.assign({}, p0, details);
1287
+ } else if (p0.type === 'special' && p0.eid) {
1288
+ const details = await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(String(p0.eid))}`);
1289
+ p = Object.assign({}, p0, details, {
1290
+ // keep backward compat with older field names used by templates below
1291
+ pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1292
+ pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1293
+ pickupLon: details.lon || details.pickupLon || p0.pickupLon,
1294
+ });
1295
+ }
1296
+ } catch (e) {
1297
+ // ignore detail fetch errors; fall back to minimal props
1298
+ p = p0;
1299
+ }
1300
+
1301
+ try {
1302
+ if (p.type === 'special') {
1198
1303
  const username = String(p.username || '').trim();
1199
1304
  const userLine = username
1200
1305
  ? `<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 +1646,9 @@ function toDatetimeLocalValue(date) {
1541
1646
  message: baseHtml,
1542
1647
  buttons,
1543
1648
  });
1649
+ } finally {
1650
+ isDialogOpen = false;
1651
+ }
1544
1652
  },
1545
1653
  });
1546
1654
 
@@ -1662,12 +1770,10 @@ function toDatetimeLocalValue(date) {
1662
1770
  try {
1663
1771
  if (!window.__oneKiteSocketBound && typeof socket !== 'undefined' && socket && typeof socket.on === 'function') {
1664
1772
  window.__oneKiteSocketBound = true;
1665
- socket.on('event:calendar-onekite.reservationUpdated', function (data) {
1773
+ socket.on('event:calendar-onekite.reservationUpdated', function () {
1666
1774
  try {
1667
1775
  const cal = window.oneKiteCalendar;
1668
- if (cal && typeof cal.refetchEvents === 'function') {
1669
- cal.refetchEvents();
1670
- }
1776
+ scheduleRefetch(cal);
1671
1777
  } catch (e) {}
1672
1778
  });
1673
1779
  }
@@ -180,14 +180,11 @@
180
180
  </div>
181
181
 
182
182
  <script>
183
- // NodeBB v4: prefer auto-init via ajaxify hook, but keep a safe fallback.
184
- if (window.require) {
185
- require(['admin/plugins/calendar-onekite'], function (mod) {
186
- if (mod && mod.init) {
187
- mod.init();
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
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.css" />
11
- <script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js"></script>
12
- <script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.11/locales-all.global.min.js"></script>
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" />
16
+ <script src="https://cdn.jsdelivr.net/npm/fullcalendar@latest/index.global.min.js"></script>
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.