nodebb-plugin-onekite-calendar 2.0.39 → 2.0.41

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,3 +1,7 @@
1
+ ## 2.0.38
2
+ - Refactor : factorisation du code client (helpers headers/CSRF) et simplification du widget.
3
+ - Widget : suppression des fallbacks de polling/visibilitychange (mise à jour via sockets uniquement) + conservation du no-cache pour éviter les 304.
4
+
1
5
  ## 2.0.37
2
6
  - Les rappels validateurs et notifications d’expiration ne sont envoyés qu’aux membres du/des groupe(s) "personnes notifiées" (notifyGroups).
3
7
 
package/lib/api.js CHANGED
@@ -359,30 +359,17 @@ async function auditLog(action, actorUid, payload) {
359
359
  }
360
360
  }
361
361
 
362
- async function canCreateSpecial(uid, settings) {
363
- if (!uid) return false;
364
- try {
365
- const isAdmin = await groups.isMember(uid, 'administrators');
366
- if (isAdmin) return true;
367
- } catch (e) {}
368
- const allowed = normalizeAllowedGroups(settings.specialCreatorGroups || '');
369
- if (!allowed.length) return false;
370
- if (await userInAnyGroup(uid, allowed)) return true;
371
-
372
- return false;
362
+ async function canCreateSpecial(uid, settings, startTs) {
363
+ // Special events ("Évènements" / "Sorties") use the same rights as creating a reservation.
364
+ // startTs is used to resolve the auto yearly group (onekite-ffvl-YYYY).
365
+ const ts = Number(startTs) || Date.now();
366
+ return await canRequest(uid, settings, ts);
373
367
  }
374
368
 
375
- async function canDeleteSpecial(uid, settings) {
376
- if (!uid) return false;
377
- try {
378
- const isAdmin = await groups.isMember(uid, 'administrators');
379
- if (isAdmin) return true;
380
- } catch (e) {}
381
- const allowed = normalizeAllowedGroups(settings.specialDeleterGroups || settings.specialCreatorGroups || '');
382
- if (!allowed.length) return false;
383
- if (await userInAnyGroup(uid, allowed)) return true;
384
-
385
- return false;
369
+ async function canDeleteSpecial(uid, settings, startTs) {
370
+ // Deletion rights are the same as creation rights (creatorGroups).
371
+ const ts = Number(startTs) || Date.now();
372
+ return await canRequest(uid, settings, ts);
386
373
  }
387
374
 
388
375
  function eventsFor(resv) {
@@ -442,23 +429,36 @@ function eventsFor(resv) {
442
429
  }
443
430
 
444
431
  function eventsForSpecial(ev) {
432
+ const kind = String(ev.kind || 'event');
445
433
  const start = new Date(parseInt(ev.start, 10));
446
434
  const end = new Date(parseInt(ev.end, 10));
447
435
  const startIso = start.toISOString();
448
436
  const endIso = end.toISOString();
437
+
438
+ // Color coding:
439
+ // - event: purple
440
+ // - outing: blue
441
+ const palette = (kind === 'outing')
442
+ ? { bg: '#0d6efd', border: '#0d6efd' }
443
+ : { bg: '#8e44ad', border: '#8e44ad' };
444
+
445
+ const defaultTitle = (kind === 'outing') ? 'Sortie' : 'Évènement';
446
+
449
447
  return {
450
448
  id: `special:${ev.eid}`,
451
- title: `${ev.title || 'Évènement'}`.trim(),
449
+ title: `${ev.title || defaultTitle}`.trim(),
452
450
  allDay: false,
453
451
  start: startIso,
454
452
  end: endIso,
455
- backgroundColor: '#8e44ad',
456
- borderColor: '#8e44ad',
453
+ backgroundColor: palette.bg,
454
+ borderColor: palette.border,
457
455
  textColor: '#ffffff',
458
456
  extendedProps: {
459
457
  type: 'special',
458
+ kind,
460
459
  eid: ev.eid,
461
- title: ev.title || '',
460
+ kind: String(ev.kind || 'event'),
461
+ title: ev.title || '',
462
462
  notes: ev.notes || '',
463
463
  pickupAddress: ev.address || '',
464
464
  pickupLat: ev.lat || '',
@@ -493,8 +493,8 @@ api.getEvents = async function (req, res) {
493
493
  const settings = await meta.settings.get('calendar-onekite');
494
494
  const canMod = req.uid ? await canValidate(req.uid, settings) : false;
495
495
  const widgetMode = String((req.query && req.query.widget) || '') === '1';
496
- const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
497
- const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
496
+ const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings, Date.now()) : false;
497
+ const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings, Date.now()) : false;
498
498
 
499
499
  // Fetch a wider window because an event can start before the query range
500
500
  // and still overlap.
@@ -611,7 +611,7 @@ api.getEvents = async function (req, res) {
611
611
  type: 'special',
612
612
  eid: sev.eid,
613
613
  canCreateSpecial: canSpecialCreate,
614
- canDeleteSpecial: canSpecialDelete,
614
+ canDeleteSpecial: canSpecialDeleteResolved,
615
615
  },
616
616
  };
617
617
  if (sev.username && (canMod || canSpecialDelete || (req.uid && String(req.uid) === String(sev.uid)))) {
@@ -696,17 +696,19 @@ api.getSpecialEventDetails = async function (req, res) {
696
696
 
697
697
  const settings = await meta.settings.get('calendar-onekite');
698
698
  const canMod = await canValidate(uid, settings);
699
- const canSpecialDelete = await canDeleteSpecial(uid, settings);
700
699
 
701
700
  const eid = String(req.params.eid || '').trim();
702
701
  if (!eid) return res.status(400).json({ error: 'missing-eid' });
703
702
  const ev = await dbLayer.getSpecialEvent(eid);
704
703
  if (!ev) return res.status(404).json({ error: 'not-found' });
705
704
 
705
+ const canSpecialDeleteResolved = await canDeleteSpecial(uid, settings, ev && ev.start ? Number(ev.start) : Date.now());
706
+
706
707
  // Anyone who can see the calendar can view special events, but creator username
707
708
  // is only visible to moderators/allowed users or the creator.
708
709
  const out = {
709
710
  eid: ev.eid,
711
+ kind: String(ev.kind || 'event'),
710
712
  title: ev.title || '',
711
713
  start: ev.start,
712
714
  end: ev.end,
@@ -714,9 +716,9 @@ api.getSpecialEventDetails = async function (req, res) {
714
716
  lat: ev.lat || '',
715
717
  lon: ev.lon || '',
716
718
  notes: ev.notes || '',
717
- canDeleteSpecial: canSpecialDelete,
719
+ canDeleteSpecial: canSpecialDeleteResolved,
718
720
  };
719
- if (ev.username && (canMod || canSpecialDelete || (uid && String(uid) === String(ev.uid)))) {
721
+ if (ev.username && (canMod || canSpecialDeleteResolved || (uid && String(uid) === String(ev.uid)))) {
720
722
  out.username = String(ev.username);
721
723
  }
722
724
  return res.json(out);
@@ -728,23 +730,28 @@ api.getCapabilities = async function (req, res) {
728
730
  const canMod = uid ? await canValidate(uid, settings) : false;
729
731
  res.json({
730
732
  canModerate: canMod,
731
- canCreateSpecial: uid ? await canCreateSpecial(uid, settings) : false,
732
- canDeleteSpecial: uid ? await canDeleteSpecial(uid, settings) : false,
733
+ canCreateSpecial: uid ? await canCreateSpecial(uid, settings, Date.now()) : false,
734
+ canDeleteSpecial: uid ? await canDeleteSpecial(uid, settings, Date.now()) : false,
733
735
  });
734
736
  };
735
737
 
736
738
  api.createSpecialEvent = async function (req, res) {
737
739
  const settings = await meta.settings.get('calendar-onekite');
738
740
  if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
739
- const ok = await canCreateSpecial(req.uid, settings);
740
- if (!ok) return res.status(403).json({ error: 'not-allowed' });
741
741
 
742
- const title = String((req.body && req.body.title) || '').trim() || 'Évènement';
742
+ const kind = String((req.body && req.body.kind) || 'event').trim().toLowerCase();
743
+ const safeKind = (kind === 'outing') ? 'outing' : 'event';
744
+
743
745
  const startTs = toTs(req.body && req.body.start);
744
746
  const endTs = toTs(req.body && req.body.end);
745
747
  if (!Number.isFinite(startTs) || !Number.isFinite(endTs) || !(startTs < endTs)) {
746
748
  return res.status(400).json({ error: 'bad-dates' });
747
749
  }
750
+
751
+ const ok = await canCreateSpecial(req.uid, settings, startTs);
752
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
753
+
754
+ const title = String((req.body && req.body.title) || '').trim() || (safeKind === 'outing' ? 'Sortie' : 'Évènement');
748
755
  const address = String((req.body && req.body.address) || '').trim();
749
756
  const notes = String((req.body && req.body.notes) || '').trim();
750
757
  const lat = String((req.body && req.body.lat) || '').trim();
@@ -754,6 +761,7 @@ api.createSpecialEvent = async function (req, res) {
754
761
  const eid = crypto.randomUUID();
755
762
  const ev = {
756
763
  eid,
764
+ kind: safeKind,
757
765
  title,
758
766
  start: String(startTs),
759
767
  end: String(endTs),
@@ -765,21 +773,41 @@ api.createSpecialEvent = async function (req, res) {
765
773
  username: u && u.username ? String(u.username) : '',
766
774
  createdAt: String(Date.now()),
767
775
  };
776
+
768
777
  await dbLayer.saveSpecialEvent(ev);
778
+
779
+ // Discord notifications (separate webhooks per kind)
780
+ try {
781
+ await discord.notifySpecialEvent(settings, 'created', ev);
782
+ } catch (e) {}
783
+
769
784
  // Real-time refresh for all viewers
770
- realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'created', eid: ev.eid });
785
+ realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'created', eid: ev.eid, specialKind: ev.kind });
771
786
  res.json({ ok: true, eid });
772
787
  };
773
788
 
774
789
  api.deleteSpecialEvent = async function (req, res) {
775
790
  const settings = await meta.settings.get('calendar-onekite');
776
791
  if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
777
- const ok = await canDeleteSpecial(req.uid, settings);
778
- if (!ok) return res.status(403).json({ error: 'not-allowed' });
792
+
779
793
  const eid = String(req.params.eid || '').replace(/^special:/, '').trim();
780
794
  if (!eid) return res.status(400).json({ error: 'bad-id' });
795
+
796
+ const ev = await dbLayer.getSpecialEvent(eid);
797
+ if (!ev) return res.status(404).json({ error: 'not-found' });
798
+
799
+ const canSpecialDeleteResolved = await canDeleteSpecial(uid, settings, ev && ev.start ? Number(ev.start) : Date.now());
800
+
801
+ const ok = await canDeleteSpecial(req.uid, settings, ev && ev.start ? Number(ev.start) : Date.now());
802
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
803
+
781
804
  await dbLayer.removeSpecialEvent(eid);
782
- realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'deleted', eid });
805
+
806
+ try {
807
+ await discord.notifySpecialEvent(settings, 'deleted', Object.assign({ eid }, ev || {}));
808
+ } catch (e) {}
809
+
810
+ realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'deleted', eid, specialKind: ev && ev.kind ? String(ev.kind) : 'event' });
783
811
  res.json({ ok: true });
784
812
  };
785
813
 
package/lib/discord.js CHANGED
@@ -183,8 +183,65 @@ async function notifyReservationCancelled(settings, reservation) {
183
183
  }
184
184
  }
185
185
 
186
+
187
+ function buildSpecialWebhookPayload(action, ev) {
188
+ const kind = String((ev && ev.kind) || 'event');
189
+ const webhookUsername = kind === 'outing'
190
+ ? (action === 'deleted' ? 'Onekite • Sortie • Annulation' : 'Onekite • Sortie')
191
+ : (action === 'deleted' ? 'Onekite • Évènement • Annulation' : 'Onekite • Évènement');
192
+
193
+ const calUrl = 'https://www.onekite.com/calendar';
194
+ const title = kind === 'outing'
195
+ ? (action === 'deleted' ? '❌ Sortie annulée' : '🗓️ Nouvelle sortie')
196
+ : (action === 'deleted' ? '❌ Évènement annulé' : '📣 Nouvel évènement');
197
+
198
+ const username = ev && ev.username ? String(ev.username) : '';
199
+ const start = ev && ev.start ? Number(ev.start) : NaN;
200
+ const end = ev && ev.end ? Number(ev.end) : NaN;
201
+ const address = ev && ev.address ? String(ev.address) : '';
202
+ const notes = ev && ev.notes ? String(ev.notes) : '';
203
+
204
+ const fields = [];
205
+ if (username) fields.push({ name: 'Créé par', value: username, inline: true });
206
+ if (Number.isFinite(start) && Number.isFinite(end)) {
207
+ fields.push({ name: 'Période', value: `Du ${formatFRShort(start)} au ${formatFRShort(end)}`, inline: false });
208
+ }
209
+ if (address) fields.push({ name: 'Adresse', value: address, inline: false });
210
+ if (notes) fields.push({ name: 'Notes', value: notes.length > 900 ? (notes.slice(0, 897) + '...') : notes, inline: false });
211
+
212
+ return {
213
+ username: webhookUsername,
214
+ content: '',
215
+ embeds: [
216
+ {
217
+ title,
218
+ url: calUrl,
219
+ description: (ev && ev.title) ? String(ev.title) : '',
220
+ fields,
221
+ footer: { text: 'Onekite • Calendrier' },
222
+ timestamp: new Date().toISOString(),
223
+ },
224
+ ],
225
+ };
226
+ }
227
+
228
+ async function notifySpecialEvent(settings, action, ev) {
229
+ const kind = String((ev && ev.kind) || 'event');
230
+ const url = kind === 'outing'
231
+ ? String((settings && settings.discordWebhookUrlOutings) || '').trim()
232
+ : String((settings && settings.discordWebhookUrlEvents) || '').trim();
233
+ if (!url) return;
234
+
235
+ try {
236
+ await postWebhook(url, buildSpecialWebhookPayload(action, ev));
237
+ } catch (e) {
238
+ console.warn('[calendar-onekite] Discord webhook failed (special)', e && e.message ? e.message : String(e));
239
+ }
240
+ }
241
+
186
242
  module.exports = {
187
243
  notifyReservationRequested,
188
244
  notifyPaymentReceived,
189
245
  notifyReservationCancelled,
246
+ notifySpecialEvent,
190
247
  };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ const groups = require.main.require('./src/groups');
4
+
5
+ async function getGroupNameBySlug(slug) {
6
+ const fn = groups && groups.getGroupNameByGroupSlug;
7
+ if (typeof fn !== 'function') return null;
8
+
9
+ const id = String(slug || '').trim();
10
+ if (!id) return null;
11
+
12
+ try {
13
+ const maybe = fn(id);
14
+ if (maybe && typeof maybe.then === 'function') {
15
+ return await maybe;
16
+ }
17
+ if (typeof maybe === 'string') {
18
+ return maybe;
19
+ }
20
+ } catch (e) {
21
+ // fall through to callback form
22
+ }
23
+
24
+ return await new Promise((resolve) => {
25
+ try {
26
+ fn(id, (err, name) => resolve(err ? null : name));
27
+ } catch (e) {
28
+ resolve(null);
29
+ }
30
+ });
31
+ }
package/lib/realtime.js CHANGED
@@ -24,10 +24,6 @@ function emitCalendarUpdated(payload) {
24
24
 
25
25
  // New event name (generic).
26
26
  server.sockets.emit('event:calendar-onekite.calendarUpdated', payload || {});
27
-
28
- // Backwards compatible event name (older clients only listened to this).
29
- // Keep it emitted for *any* calendar mutation, not just reservation status.
30
- server.sockets.emit('event:calendar-onekite.reservationUpdated', payload || {});
31
27
  } catch (e) {}
32
28
  }
33
29
 
package/lib/widgets.js CHANGED
@@ -392,19 +392,19 @@ dateClick: function() { window.location.href = calUrl; },
392
392
  calendar.render();
393
393
 
394
394
  // Real-time refresh for the widget (same server events as the main calendar)
395
+ // We intentionally rely on sockets only (no periodic polling fallback):
396
+ // - keeps the widget lightweight
397
+ // - avoids pointless API traffic
398
+ // - updates are already broadcast server-side across NodeBB instances
395
399
  try {
396
400
  // Debounce per widget instance
397
401
  let tRefetch = null;
398
402
  const refetch = function () {
399
- try {
400
- if (tRefetch) return;
401
- tRefetch = setTimeout(() => {
402
- tRefetch = null;
403
- try {
404
- calendar.refetchEvents();
405
- } catch (e) {}
406
- }, 200);
407
- } catch (e) {}
403
+ if (tRefetch) return;
404
+ tRefetch = setTimeout(() => {
405
+ tRefetch = null;
406
+ try { calendar.refetchEvents(); } catch (e) {}
407
+ }, 200);
408
408
  };
409
409
 
410
410
  // Register refetcher and bind socket listeners once per page
@@ -414,34 +414,15 @@ dateClick: function() { window.location.href = calUrl; },
414
414
  if (!window.__oneKiteWidgetSocketBound && typeof socket !== 'undefined' && socket && typeof socket.on === 'function') {
415
415
  window.__oneKiteWidgetSocketBound = true;
416
416
  const triggerAll = function () {
417
- try {
418
- const list = window.__oneKiteWidgetRefetchers || [];
419
- for (let i = 0; i < list.length; i += 1) {
420
- try { list[i](); } catch (e) {}
421
- }
422
- } catch (e) {}
417
+ const list = window.__oneKiteWidgetRefetchers || [];
418
+ for (let i = 0; i < list.length; i += 1) {
419
+ try { list[i](); } catch (e) {}
420
+ }
423
421
  };
424
422
  socket.on('event:calendar-onekite.calendarUpdated', triggerAll);
425
- socket.on('event:calendar-onekite.reservationUpdated', triggerAll);
426
423
  }
427
424
  } catch (e) {}
428
425
 
429
- // Fallback refresh: in case sockets are unavailable or blocked by proxies,
430
- // refetch periodically and when the tab becomes visible.
431
- try {
432
- window.__oneKiteWidgetIntervals = window.__oneKiteWidgetIntervals || {};
433
- if (!window.__oneKiteWidgetIntervals[containerId]) {
434
- window.__oneKiteWidgetIntervals[containerId] = setInterval(() => {
435
- try { calendar.refetchEvents(); } catch (e) {}
436
- }, 60000);
437
- }
438
- document.addEventListener('visibilitychange', () => {
439
- try {
440
- if (!document.hidden) calendar.refetchEvents();
441
- } catch (e) {}
442
- }, { passive: true });
443
- } catch (e) {}
444
-
445
426
  // Mobile swipe (left/right) to navigate weeks
446
427
  try {
447
428
  let touchStartX = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.39",
3
+ "version": "2.0.41",
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
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "2.0.39"
42
+ "version": "2.0.41"
43
43
  }
package/public/admin.js CHANGED
@@ -109,48 +109,32 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
109
109
  }
110
110
 
111
111
  async function fetchJson(url, opts) {
112
- const res = await fetch(url, {
113
- credentials: 'same-origin',
114
- headers: (() => {
115
- const headers = { 'Content-Type': 'application/json' };
116
- const token =
112
+ const getCsrfToken = () => {
113
+ try {
114
+ return (
117
115
  (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
118
116
  (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
119
117
  (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
120
118
  (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
121
119
  (typeof app !== 'undefined' && app && app.csrfToken) ||
122
- null;
123
- if (token) headers['x-csrf-token'] = token;
124
- return headers;
125
- })(),
120
+ null
121
+ );
122
+ } catch (e) {
123
+ return null;
124
+ }
125
+ };
126
+
127
+ const headers = { 'Content-Type': 'application/json' };
128
+ const token = getCsrfToken();
129
+ if (token) headers['x-csrf-token'] = token;
130
+
131
+ const res = await fetch(url, {
132
+ credentials: 'same-origin',
133
+ headers,
126
134
  ...opts,
127
135
  });
128
136
 
129
-
130
137
  if (!res.ok) {
131
- // NodeBB versions differ: some expose admin APIs under /api/admin instead of /api/v3/admin
132
- if (res.status === 404 && typeof url === 'string' && url.includes('/api/v3/admin/')) {
133
- const altUrl = url.replace('/api/v3/admin/', '/api/admin/');
134
- const res2 = await fetch(altUrl, {
135
- credentials: 'same-origin',
136
- headers: (() => {
137
- const headers = { 'Content-Type': 'application/json' };
138
- const token =
139
- (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
140
- (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
141
- (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
142
- (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
143
- (typeof app !== 'undefined' && app && app.csrfToken) ||
144
- null;
145
- if (token) headers['x-csrf-token'] = token;
146
- return headers;
147
- })(),
148
- ...opts,
149
- });
150
- if (res2.ok) {
151
- return await res2.json();
152
- }
153
- }
154
138
  const text = await res.text().catch(() => '');
155
139
  throw new Error(`${res.status} ${text}`);
156
140
  }
package/public/client.js CHANGED
@@ -138,6 +138,28 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
138
138
  .replace(/'/g, '&#39;');
139
139
  }
140
140
 
141
+ function getCsrfToken() {
142
+ try {
143
+ return (
144
+ (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
145
+ (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
146
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
147
+ (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
148
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
149
+ null
150
+ );
151
+ } catch (e) {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function jsonHeaders(extra) {
157
+ const headers = Object.assign({ 'Content-Type': 'application/json' }, extra || {});
158
+ const token = getCsrfToken();
159
+ if (token) headers['x-csrf-token'] = token;
160
+ return headers;
161
+ }
162
+
141
163
  function pad2(n) { return String(n).padStart(2, '0'); }
142
164
 
143
165
  function toDateInputValue(d) {
@@ -186,7 +208,12 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
186
208
  return opts.join('');
187
209
  }
188
210
 
189
- async function openSpecialEventDialog(selectionInfo) {
211
+ async function openSpecialEventDialog(selectionInfo, opts) {
212
+ const options = opts || {};
213
+ const kind = (String(options.kind || 'event').trim().toLowerCase() === 'outing') ? 'outing' : 'event';
214
+ const dialogTitle = String(options.dialogTitle || (kind === 'outing' ? 'Créer une prévision de sortie' : 'Créer un évènement'));
215
+ const defaultTitle = String(options.defaultTitle || (kind === 'outing' ? 'Sortie' : 'Évènement'));
216
+
190
217
  const start = selectionInfo.start;
191
218
  // FullCalendar can omit `end` for certain interactions. Also, for all-day
192
219
  // selections, `end` is exclusive (next day at 00:00). We normalise below
@@ -240,7 +267,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
240
267
  const html = `
241
268
  <div class="mb-3">
242
269
  <label class="form-label">Titre</label>
243
- <input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." />
270
+ <input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." value="${escapeHtml(defaultTitle)}" />
244
271
  </div>
245
272
  <div class="row g-2">
246
273
  <div class="col-12 col-md-6">
@@ -285,7 +312,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
285
312
  return await new Promise((resolve) => {
286
313
  let resolved = false;
287
314
  const dialog = bootbox.dialog({
288
- title: 'Créer un évènement',
315
+ title: dialogTitle,
289
316
  message: html,
290
317
  buttons: {
291
318
  cancel: {
@@ -334,7 +361,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
334
361
  const lat = (document.getElementById('onekite-se-lat')?.value || '').trim();
335
362
  const lon = (document.getElementById('onekite-se-lon')?.value || '').trim();
336
363
  resolved = true;
337
- resolve({ title, start: startVal, end: endVal, address, notes, lat, lon });
364
+ resolve({ kind, title, start: startVal, end: endVal, address, notes, lat, lon });
338
365
  return true;
339
366
  },
340
367
  },
@@ -502,18 +529,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
502
529
  async function fetchJson(url, opts) {
503
530
  const res = await fetch(url, {
504
531
  credentials: 'same-origin',
505
- headers: (() => {
506
- const headers = { 'Content-Type': 'application/json' };
507
- const token =
508
- (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
509
- (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
510
- (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
511
- (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
512
- (typeof app !== 'undefined' && app && app.csrfToken) ||
513
- null;
514
- if (token) headers['x-csrf-token'] = token;
515
- return headers;
516
- })(),
532
+ headers: jsonHeaders((opts && opts.headers) || {}),
517
533
  ...opts,
518
534
  });
519
535
  if (!res.ok) {
@@ -563,19 +579,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
563
579
  try {
564
580
  res = await fetch(url, {
565
581
  credentials: 'same-origin',
566
- headers: (() => {
567
- // reuse csrf header builder (fetchJson) by calling it indirectly
568
- const base = { 'Content-Type': 'application/json' };
569
- const token =
570
- (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
571
- (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
572
- (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
573
- (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
574
- (typeof app !== 'undefined' && app && app.csrfToken) ||
575
- null;
576
- if (token) base['x-csrf-token'] = token;
577
- return Object.assign(base, headers);
578
- })(),
582
+ headers: jsonHeaders(headers),
579
583
  ...opts,
580
584
  });
581
585
  } catch (e) {
@@ -1359,6 +1363,55 @@ function toDatetimeLocalValue(date) {
1359
1363
  return;
1360
1364
  }
1361
1365
 
1366
+
1367
+
1368
+ // On a day click / selection in reservation mode, propose the user to create either
1369
+ // a Location (reservation) or a "Prévision de sortie" (special event).
1370
+ try {
1371
+ const choice = await new Promise((resolve) => {
1372
+ bootbox.dialog({
1373
+ title: 'Créer',
1374
+ message: '<div>Que veux-tu créer ?</div>',
1375
+ buttons: {
1376
+ cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(null) },
1377
+ outing: { label: 'Prévision de sortie', className: 'btn-info', callback: () => resolve('outing') },
1378
+ reservation: { label: 'Location', className: 'btn-primary', callback: () => resolve('reservation') },
1379
+ },
1380
+ });
1381
+ });
1382
+
1383
+ if (!choice) {
1384
+ calendar.unselect();
1385
+ isDialogOpen = false;
1386
+ return;
1387
+ }
1388
+
1389
+ if (choice === 'outing') {
1390
+ const payload = await openSpecialEventDialog(info, { kind: 'outing', dialogTitle: 'Créer une prévision de sortie', defaultTitle: 'Sortie' });
1391
+ if (!payload) {
1392
+ calendar.unselect();
1393
+ isDialogOpen = false;
1394
+ return;
1395
+ }
1396
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1397
+ method: 'POST',
1398
+ body: JSON.stringify(payload),
1399
+ }).catch(async () => {
1400
+ return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1401
+ method: 'POST',
1402
+ body: JSON.stringify(payload),
1403
+ });
1404
+ });
1405
+ showAlert('success', 'Sortie créée.');
1406
+ invalidateEventsCache();
1407
+ scheduleRefetch(calendar);
1408
+ calendar.unselect();
1409
+ isDialogOpen = false;
1410
+ return;
1411
+ }
1412
+ } catch (e) {
1413
+ // ignore
1414
+ }
1362
1415
  // Business rule: reservations cannot start in the past.
1363
1416
  // (We validate again on the server, but this gives immediate feedback.)
1364
1417
  try {
@@ -1545,15 +1598,19 @@ function toDatetimeLocalValue(date) {
1545
1598
  }
1546
1599
  },
1547
1600
  eventDidMount: function (arg) {
1548
- // Keep special event colors consistent.
1601
+ // Keep special event colors consistent (kind-specific).
1549
1602
  try {
1550
1603
  const ev = arg && arg.event;
1551
1604
  if (!ev) return;
1552
1605
  if (ev.extendedProps && ev.extendedProps.type === 'special') {
1606
+ const kind = String((ev.extendedProps.kind || 'event'));
1607
+ const palette = (kind === 'outing')
1608
+ ? { bg: '#0d6efd', border: '#0d6efd' }
1609
+ : { bg: '#8e44ad', border: '#8e44ad' };
1553
1610
  const el2 = arg.el;
1554
1611
  if (el2 && el2.style) {
1555
- el2.style.backgroundColor = '#8e44ad';
1556
- el2.style.borderColor = '#8e44ad';
1612
+ el2.style.backgroundColor = palette.bg;
1613
+ el2.style.borderColor = palette.border;
1557
1614
  el2.style.color = '#ffffff';
1558
1615
  }
1559
1616
  }
@@ -1599,6 +1656,8 @@ function toDatetimeLocalValue(date) {
1599
1656
 
1600
1657
  try {
1601
1658
  if (p.type === 'special') {
1659
+ const specialKind = String((p.kind || p0.kind || 'event'));
1660
+ const specialLabel = (specialKind === 'outing') ? 'Sortie' : 'Évènement';
1602
1661
  const username = String(p.username || '').trim();
1603
1662
  const userLine = username
1604
1663
  ? `<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>`
@@ -1622,7 +1681,7 @@ function toDatetimeLocalValue(date) {
1622
1681
  `;
1623
1682
  const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
1624
1683
  bootbox.dialog({
1625
- title: 'Évènement',
1684
+ title: specialLabel,
1626
1685
  message: html,
1627
1686
  buttons: {
1628
1687
  close: { label: 'Fermer', className: 'btn-secondary' },
@@ -1634,7 +1693,7 @@ function toDatetimeLocalValue(date) {
1634
1693
  try {
1635
1694
  const eid = String(p.eid || ev.id).replace(/^special:/, '');
1636
1695
  await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' });
1637
- showAlert('success', 'Évènement supprimé.');
1696
+ showAlert('success', `${specialLabel} supprimé.`);
1638
1697
  calendar.refetchEvents();
1639
1698
  } catch (e) {
1640
1699
  showAlert('error', 'Suppression impossible.');
@@ -2153,6 +2212,40 @@ async function openFabDatePicker() {
2153
2212
  try {
2154
2213
  if (isDialogOpen) return;
2155
2214
  isDialogOpen = true;
2215
+
2216
+ // Let the user pick between a Location or an Outing forecast.
2217
+ const choice = await new Promise((resolve) => {
2218
+ bootbox.dialog({
2219
+ title: 'Créer',
2220
+ message: '<div>Que veux-tu créer ?</div>',
2221
+ buttons: {
2222
+ cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(null) },
2223
+ outing: { label: 'Prévision de sortie', className: 'btn-info', callback: () => resolve('outing') },
2224
+ reservation: { label: 'Location', className: 'btn-primary', callback: () => resolve('reservation') },
2225
+ },
2226
+ });
2227
+ });
2228
+
2229
+ if (!choice) return;
2230
+
2231
+ if (choice === 'outing') {
2232
+ const payload = await openSpecialEventDialog({ start: s, end: endExcl, allDay: true }, { kind: 'outing', dialogTitle: 'Créer une prévision de sortie', defaultTitle: 'Sortie' });
2233
+ if (!payload) return;
2234
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
2235
+ method: 'POST',
2236
+ body: JSON.stringify(payload),
2237
+ }).catch(async () => {
2238
+ return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
2239
+ method: 'POST',
2240
+ body: JSON.stringify(payload),
2241
+ });
2242
+ });
2243
+ showAlert('success', 'Sortie créée.');
2244
+ invalidateEventsCache();
2245
+ if (currentCalendar) scheduleRefetch(currentCalendar);
2246
+ return;
2247
+ }
2248
+
2156
2249
  const items = cachedItems || (await loadItems());
2157
2250
  const chosen = await openReservationDialog({ start: s, end: endExcl }, items);
2158
2251
  if (chosen && chosen.itemIds && chosen.itemIds.length) {
@@ -2383,12 +2476,6 @@ try {
2383
2476
  scheduleRefetch(cal);
2384
2477
  } catch (e) {}
2385
2478
  });
2386
- socket.on('event:calendar-onekite.reservationUpdated', function () {
2387
- try {
2388
- const cal = window.oneKiteCalendar;
2389
- scheduleRefetch(cal);
2390
- } catch (e) {}
2391
- });
2392
2479
  }
2393
2480
  } catch (e) {}
2394
2481
 
@@ -80,6 +80,19 @@
80
80
  <div class="form-text">Si vide, aucune notification Discord n'est envoyée.</div>
81
81
  </div>
82
82
 
83
+ <div class="mb-3">
84
+ <label class="form-label">Webhook URL (Évènements)</label>
85
+ <input class="form-control" name="discordWebhookUrlEvents" placeholder="https://discord.com/api/webhooks/...">
86
+ <div class="form-text">Notifications Discord pour les <strong>évènements</strong> (création & annulation). Si vide, aucune notification n'est envoyée.</div>
87
+ </div>
88
+
89
+ <div class="mb-3">
90
+ <label class="form-label">Webhook URL (Sorties)</label>
91
+ <input class="form-control" name="discordWebhookUrlOutings" placeholder="https://discord.com/api/webhooks/...">
92
+ <div class="form-text">Notifications Discord pour les <strong>sorties</strong> (création & annulation). Si vide, aucune notification n'est envoyée.</div>
93
+ </div>
94
+
95
+
83
96
  <div class="mb-3">
84
97
  <label class="form-label">Envoyer une notification à la demande</label>
85
98
  <select class="form-select" name="discordNotifyOnRequest">
@@ -157,17 +170,6 @@
157
170
  <div class="tab-pane fade" id="onekite-tab-events" role="tabpanel">
158
171
  <h4>Évènements (autre couleur)</h4>
159
172
  <div class="form-text mb-3">Permet de créer des évènements horaires (début/fin) avec adresse (Leaflet) et notes.</div>
160
-
161
- <div class="mb-3">
162
- <label class="form-label">Groupes autorisés à créer des évènements (csv)</label>
163
- <input class="form-control" name="specialCreatorGroups" placeholder="ex: staff,instructors">
164
- </div>
165
-
166
- <div class="mb-3">
167
- <label class="form-label">Groupes autorisés à supprimer des évènements (csv)</label>
168
- <input class="form-control" name="specialDeleterGroups" placeholder="Si vide: même liste que la création">
169
- </div>
170
-
171
173
  <hr class="my-4" />
172
174
  <h4>Purge des évènements</h4>
173
175
  <div class="d-flex gap-2 align-items-center">