nodebb-plugin-onekite-calendar 2.0.41 → 2.0.43

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/public/client.js CHANGED
@@ -208,12 +208,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
208
208
  return opts.join('');
209
209
  }
210
210
 
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
-
211
+ async function openSpecialEventDialog(selectionInfo) {
217
212
  const start = selectionInfo.start;
218
213
  // FullCalendar can omit `end` for certain interactions. Also, for all-day
219
214
  // selections, `end` is exclusive (next day at 00:00). We normalise below
@@ -267,7 +262,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
267
262
  const html = `
268
263
  <div class="mb-3">
269
264
  <label class="form-label">Titre</label>
270
- <input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." value="${escapeHtml(defaultTitle)}" />
265
+ <input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." />
271
266
  </div>
272
267
  <div class="row g-2">
273
268
  <div class="col-12 col-md-6">
@@ -312,7 +307,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
312
307
  return await new Promise((resolve) => {
313
308
  let resolved = false;
314
309
  const dialog = bootbox.dialog({
315
- title: dialogTitle,
310
+ title: 'Créer un évènement',
316
311
  message: html,
317
312
  buttons: {
318
313
  cancel: {
@@ -361,7 +356,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
361
356
  const lat = (document.getElementById('onekite-se-lat')?.value || '').trim();
362
357
  const lon = (document.getElementById('onekite-se-lon')?.value || '').trim();
363
358
  resolved = true;
364
- resolve({ kind, title, start: startVal, end: endVal, address, notes, lat, lon });
359
+ resolve({ title, start: startVal, end: endVal, address, notes, lat, lon });
365
360
  return true;
366
361
  },
367
362
  },
@@ -432,6 +427,17 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
432
427
  });
433
428
  }
434
429
 
430
+
431
+
432
+ async function openOutingDialog(selectionInfo) {
433
+ // Same UI as special events, but saved as "Sorties" (prévisions).
434
+ const payload = await openSpecialEventDialog(selectionInfo);
435
+ if (!payload) return null;
436
+ // Default title if empty
437
+ if (!payload.title) payload.title = 'Prévision de sortie';
438
+ return payload;
439
+ }
440
+
435
441
  async function openMapViewer(title, address, lat, lon) {
436
442
  const mapId = `onekite-map-view-${Date.now()}-${Math.floor(Math.random()*10000)}`;
437
443
  const safeAddr = (address || '').trim();
@@ -1163,97 +1169,7 @@ function toDatetimeLocalValue(date) {
1163
1169
  const canCreateSpecial = !!caps.canCreateSpecial;
1164
1170
  const canDeleteSpecial = !!caps.canDeleteSpecial;
1165
1171
 
1166
- // Current creation mode: reservation (location) or special (event).
1167
- // Persist the user's last choice so actions (create/approve/refuse/refetch) don't reset it.
1168
- const modeStorageKey = (() => {
1169
- try {
1170
- const uid = (typeof app !== 'undefined' && app && app.user && (app.user.uid || app.user.uid === 0)) ? String(app.user.uid)
1171
- : (window.ajaxify && window.ajaxify.data && (window.ajaxify.data.uid || window.ajaxify.data.uid === 0)) ? String(window.ajaxify.data.uid)
1172
- : '0';
1173
- return `onekiteCalendarMode:${uid}`;
1174
- } catch (e) {
1175
- return 'onekiteCalendarMode:0';
1176
- }
1177
- })();
1178
-
1179
- function loadSavedMode() {
1180
- try {
1181
- const v = (window.localStorage && window.localStorage.getItem(modeStorageKey)) || '';
1182
- return (v === 'special' || v === 'reservation') ? v : 'reservation';
1183
- } catch (e) {
1184
- return 'reservation';
1185
- }
1186
- }
1187
-
1188
- function saveMode(v) {
1189
- try {
1190
- if (!window.localStorage) return;
1191
- window.localStorage.setItem(modeStorageKey, v);
1192
- } catch (e) {
1193
- // ignore
1194
- }
1195
- }
1196
-
1197
- let mode = loadSavedMode();
1198
- // Avoid showing the "mode évènement" hint multiple times (desktop + mobile handlers)
1199
- let lastModeHintAt = 0;
1200
-
1201
- function refreshDesktopModeButton() {
1202
- try {
1203
- const btn = document.querySelector('#onekite-calendar .fc-newSpecial-button');
1204
- if (!btn) return;
1205
-
1206
- const isSpecial = mode === 'special';
1207
- const label = isSpecial ? 'Évènement ✓' : 'Évènement';
1208
-
1209
- // Ensure a single canonical .fc-button-text (prevents "ÉvènementÉvènement" after rerenders)
1210
- let span = btn.querySelector('.fc-button-text');
1211
- if (!span) {
1212
- span = document.createElement('span');
1213
- span.className = 'fc-button-text';
1214
- // Remove stray text nodes before inserting span
1215
- [...btn.childNodes].forEach((n) => {
1216
- if (n && n.nodeType === Node.TEXT_NODE) n.remove();
1217
- });
1218
- btn.appendChild(span);
1219
- } else {
1220
- // Remove any stray text nodes beside the span
1221
- [...btn.childNodes].forEach((n) => {
1222
- if (n && n.nodeType === Node.TEXT_NODE && n.textContent.trim()) n.remove();
1223
- });
1224
- }
1225
-
1226
- span.textContent = label;
1227
- btn.classList.toggle('onekite-active', isSpecial);
1228
- } catch (e) {}
1229
- }
1230
-
1231
- function setMode(next, opts) {
1232
- if (next !== 'reservation' && next !== 'special') return;
1233
- mode = next;
1234
- saveMode(mode);
1235
-
1236
- // Reset any pending selection/dialog state when switching modes
1237
- try { if (mode === 'reservation') { calendar.unselect(); isDialogOpen = false; } } catch (e) {}
1238
-
1239
- const silent = !!(opts && opts.silent);
1240
- if (!silent && mode === 'special') {
1241
- const now = Date.now();
1242
- if (now - lastModeHintAt > 1200) {
1243
- lastModeHintAt = now;
1244
- showAlert('success', 'Mode évènement : sélectionnez une date ou une plage');
1245
- }
1246
- }
1247
- refreshDesktopModeButton();
1248
- try {
1249
- const mb = document.querySelector('#onekite-mobile-controls .onekite-mode-btn');
1250
- if (mb) {
1251
- const isSpecial = mode === 'special';
1252
- mb.textContent = isSpecial ? 'Mode évènement ✓' : 'Mode évènement';
1253
- mb.classList.toggle('onekite-active', isSpecial);
1254
- }
1255
- } catch (e) {}
1256
- }
1172
+ // Creation chooser: Location / Prévision de sortie / Évènement (si autorisé).
1257
1173
 
1258
1174
  // Inject lightweight responsive CSS once.
1259
1175
  try {
@@ -1320,149 +1236,29 @@ function toDatetimeLocalValue(date) {
1320
1236
  left: 'prev,next today',
1321
1237
  center: 'title',
1322
1238
  // Only month + week (no day view)
1323
- right: (canCreateSpecial ? 'newSpecial ' : '') + 'dayGridMonth,timeGridWeek',
1239
+ right: 'dayGridMonth,timeGridWeek',
1324
1240
  };
1325
1241
 
1326
1242
  let calendar;
1327
1243
 
1328
- // Unified handler for creation actions (reservations vs special events).
1329
- // On mobile, FullCalendar may emit `dateClick` but not `select` for a simple tap.
1330
- // We therefore support both without calling `calendar.select()` (which could
1331
- // double-trigger `select`).
1244
+ // Unified handler for creation actions via a chooser modal.
1332
1245
  async function handleCreateFromSelection(info) {
1333
- if (isDialogOpen) {
1334
- return;
1335
- }
1336
- // Avoid double-taps creating two dialogs / two requests.
1337
- if (!lockAction('create', 900)) {
1338
- return;
1339
- }
1340
- isDialogOpen = true;
1246
+ if (isDialogOpen) return;
1247
+ if (!lockAction('create', 900)) return;
1248
+
1249
+ // Business rule: nothing can be created in the past.
1341
1250
  try {
1342
- if (mode === 'special' && canCreateSpecial) {
1343
- const payload = await openSpecialEventDialog(info);
1344
- if (!payload) {
1345
- calendar.unselect();
1346
- isDialogOpen = false;
1347
- return;
1348
- }
1349
- await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1350
- method: 'POST',
1351
- body: JSON.stringify(payload),
1352
- }).catch(async () => {
1353
- return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1354
- method: 'POST',
1355
- body: JSON.stringify(payload),
1356
- });
1357
- });
1358
- showAlert('success', 'Évènement créé.');
1359
- invalidateEventsCache();
1360
- scheduleRefetch(calendar);
1361
- calendar.unselect();
1362
- isDialogOpen = false;
1251
+ const startDateCheck = toLocalYmd(info.start);
1252
+ const todayCheck = toLocalYmd(new Date());
1253
+ if (startDateCheck < todayCheck) {
1254
+ lastDateRuleToastAt = Date.now();
1255
+ showAlert('error', "Impossible de créer pour une date passée.");
1256
+ try { calendar.unselect(); } catch (e) {}
1363
1257
  return;
1364
1258
  }
1259
+ } catch (e) {}
1365
1260
 
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
- }
1415
- // Business rule: reservations cannot start in the past.
1416
- // (We validate again on the server, but this gives immediate feedback.)
1417
- try {
1418
- const startDateCheck = toLocalYmd(info.start);
1419
- const todayCheck = toLocalYmd(new Date());
1420
- if (startDateCheck < todayCheck) {
1421
- lastDateRuleToastAt = Date.now();
1422
- showAlert('error', "Impossible de réserver pour une date passée.");
1423
- calendar.unselect();
1424
- isDialogOpen = false;
1425
- return;
1426
- }
1427
- } catch (e) {
1428
- // ignore
1429
- }
1430
-
1431
- if (!items || !items.length) {
1432
- showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1433
- calendar.unselect();
1434
- isDialogOpen = false;
1435
- return;
1436
- }
1437
- const chosen = await openReservationDialog(info, items);
1438
- if (!chosen || !chosen.itemIds || !chosen.itemIds.length) {
1439
- calendar.unselect();
1440
- isDialogOpen = false;
1441
- return;
1442
- }
1443
- // Send date strings (no hours) so reservations are day-based.
1444
- const startDate = toLocalYmd(info.start);
1445
- // NOTE: FullCalendar's `info.end` reflects the original selection.
1446
- // If the user used "Durée rapide", the effective end date is held
1447
- // inside the dialog (returned as `chosen.endDate`).
1448
- const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
1449
- const resp = await requestReservation({
1450
- start: startDate,
1451
- end: endDate,
1452
- itemIds: chosen.itemIds,
1453
- itemNames: chosen.itemNames,
1454
- total: chosen.total,
1455
- });
1456
- if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
1457
- showAlert('success', 'Réservation confirmée.');
1458
- } else {
1459
- showAlert('success', 'Demande envoyée (en attente de validation).');
1460
- }
1461
- invalidateEventsCache();
1462
- scheduleRefetch(calendar);
1463
- calendar.unselect();
1464
- isDialogOpen = false;
1465
- } catch (e) {
1261
+ function handleCreateError(e) {
1466
1262
  const code = String((e && (e.status || e.message)) || '');
1467
1263
  const payload = e && e.payload ? e.payload : null;
1468
1264
 
@@ -1470,33 +1266,129 @@ function toDatetimeLocalValue(date) {
1470
1266
  const msg = payload && (payload.message || payload.error || payload.msg) ? String(payload.message || payload.error || payload.msg) : '';
1471
1267
  const c = payload && payload.code ? String(payload.code) : '';
1472
1268
  if (c === 'NOT_MEMBER' || /adh(é|e)rent/i.test(msg) || /membership/i.test(msg)) {
1473
- showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer une réservation.');
1269
+ showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer cette action.');
1474
1270
  } else {
1475
- showAlert('error', msg || 'Impossible de créer la demande : droits insuffisants (groupe).');
1271
+ showAlert('error', msg || 'Action impossible : droits insuffisants (groupe).');
1476
1272
  }
1477
1273
  } else if (code === '409') {
1478
- showAlert('error', 'Impossible : au moins un matériel est déjà réservé ou en attente sur cette période.');
1274
+ showAlert('error', 'Impossible : conflit de réservation sur cette période.');
1479
1275
  } else if (code === '400' && payload && (payload.error === 'date-too-soon' || payload.code === 'date-too-soon')) {
1480
- // If we already showed the client-side toast a moment ago, avoid a duplicate.
1481
1276
  if (!lastDateRuleToastAt || (Date.now() - lastDateRuleToastAt) > 1500) {
1482
- showAlert('error', String(payload.message || "Impossible de réserver pour une date passée."));
1277
+ showAlert('error', String(payload.message || "Impossible de créer pour une date passée."));
1483
1278
  }
1484
1279
  } else {
1485
1280
  const msgRaw = payload && (payload.message || payload.error || payload.msg)
1486
1281
  ? String(payload.message || payload.error || payload.msg)
1487
1282
  : '';
1488
-
1489
- // NodeBB can return a plain "not-logged-in" string when the user is not authenticated.
1490
- // We want a user-friendly message consistent with the membership requirement.
1491
1283
  const msg = (/\bnot-logged-in\b/i.test(msgRaw) || /\[\[error:not-logged-in\]\]/i.test(msgRaw))
1492
1284
  ? 'Vous devez être adhérent Onekite'
1493
1285
  : msgRaw;
1494
-
1495
- showAlert('error', msg || ((e && (e.status === 401 || e.status === 403)) ? 'Vous devez être adhérent Onekite' : 'Erreur lors de la création de la demande.'));
1286
+ showAlert('error', msg || 'Erreur lors de la création.');
1496
1287
  }
1497
- calendar.unselect();
1498
- isDialogOpen = false;
1499
1288
  }
1289
+
1290
+ const buttons = {
1291
+ close: { label: 'Annuler', className: 'btn-secondary' },
1292
+ location: {
1293
+ label: 'Location',
1294
+ className: 'btn-primary',
1295
+ callback: async () => {
1296
+ try {
1297
+ isDialogOpen = true;
1298
+ if (!items || !items.length) {
1299
+ showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1300
+ return;
1301
+ }
1302
+ const chosen = await openReservationDialog(info, items);
1303
+ if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1304
+ const startDate = toLocalYmd(info.start);
1305
+ const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
1306
+ const resp = await requestReservation({
1307
+ start: startDate,
1308
+ end: endDate,
1309
+ itemIds: chosen.itemIds,
1310
+ itemNames: chosen.itemNames,
1311
+ total: chosen.total,
1312
+ });
1313
+ if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
1314
+ showAlert('success', 'Réservation confirmée.');
1315
+ } else {
1316
+ showAlert('success', 'Demande envoyée (en attente de validation).');
1317
+ }
1318
+ invalidateEventsCache();
1319
+ scheduleRefetch(calendar);
1320
+ } catch (e) {
1321
+ handleCreateError(e);
1322
+ } finally {
1323
+ try { calendar.unselect(); } catch (e) {}
1324
+ isDialogOpen = false;
1325
+ }
1326
+ return true;
1327
+ },
1328
+ },
1329
+ outing: {
1330
+ label: 'Prévision de sortie',
1331
+ className: 'btn-outline-primary',
1332
+ callback: async () => {
1333
+ try {
1334
+ isDialogOpen = true;
1335
+ const payload = await openOutingDialog(info);
1336
+ if (!payload) return;
1337
+ await fetchJson('/api/v3/plugins/calendar-onekite/outings', {
1338
+ method: 'POST',
1339
+ body: JSON.stringify(payload),
1340
+ });
1341
+ showAlert('success', 'Prévision de sortie créée.');
1342
+ invalidateEventsCache();
1343
+ scheduleRefetch(calendar);
1344
+ } catch (e) {
1345
+ handleCreateError(e);
1346
+ } finally {
1347
+ try { calendar.unselect(); } catch (e) {}
1348
+ isDialogOpen = false;
1349
+ }
1350
+ return true;
1351
+ },
1352
+ },
1353
+ };
1354
+
1355
+ if (canCreateSpecial) {
1356
+ buttons.special = {
1357
+ label: 'Évènement',
1358
+ className: 'btn-outline-secondary',
1359
+ callback: async () => {
1360
+ try {
1361
+ isDialogOpen = true;
1362
+ const payload = await openSpecialEventDialog(info);
1363
+ if (!payload) return;
1364
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1365
+ method: 'POST',
1366
+ body: JSON.stringify(payload),
1367
+ }).catch(async () => {
1368
+ return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1369
+ method: 'POST',
1370
+ body: JSON.stringify(payload),
1371
+ });
1372
+ });
1373
+ showAlert('success', 'Évènement créé.');
1374
+ invalidateEventsCache();
1375
+ scheduleRefetch(calendar);
1376
+ } catch (e) {
1377
+ handleCreateError(e);
1378
+ } finally {
1379
+ try { calendar.unselect(); } catch (e) {}
1380
+ isDialogOpen = false;
1381
+ }
1382
+ return true;
1383
+ },
1384
+ };
1385
+ }
1386
+
1387
+ bootbox.dialog({
1388
+ title: 'Créer',
1389
+ message: '<div class="text-muted">Que veux-tu créer sur ces dates ?</div>',
1390
+ buttons,
1391
+ });
1500
1392
  }
1501
1393
 
1502
1394
  calendar = new FullCalendar.Calendar(el, {
@@ -1509,14 +1401,7 @@ function toDatetimeLocalValue(date) {
1509
1401
  headerToolbar: headerToolbar,
1510
1402
  // Keep titles short on mobile to avoid horizontal overflow
1511
1403
  titleFormat: isMobileNow() ? { year: 'numeric', month: 'short' } : undefined,
1512
- customButtons: canCreateSpecial ? {
1513
- newSpecial: {
1514
- text: 'Évènement',
1515
- click: () => {
1516
- setMode((mode === 'special') ? 'reservation' : 'special');
1517
- },
1518
- },
1519
- } : {},
1404
+ customButtons: {},
1520
1405
  // We display the time ourselves inside the title for "special" events,
1521
1406
  // to match reservation icons and avoid FullCalendar's fixed-width time column.
1522
1407
  displayEventTime: false,
@@ -1598,19 +1483,15 @@ function toDatetimeLocalValue(date) {
1598
1483
  }
1599
1484
  },
1600
1485
  eventDidMount: function (arg) {
1601
- // Keep special event colors consistent (kind-specific).
1486
+ // Keep special event colors consistent.
1602
1487
  try {
1603
1488
  const ev = arg && arg.event;
1604
1489
  if (!ev) return;
1605
1490
  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' };
1610
1491
  const el2 = arg.el;
1611
1492
  if (el2 && el2.style) {
1612
- el2.style.backgroundColor = palette.bg;
1613
- el2.style.borderColor = palette.border;
1493
+ el2.style.backgroundColor = '#8e44ad';
1494
+ el2.style.borderColor = '#8e44ad';
1614
1495
  el2.style.color = '#ffffff';
1615
1496
  }
1616
1497
  }
@@ -1643,7 +1524,13 @@ function toDatetimeLocalValue(date) {
1643
1524
  } else if (p0.type === 'special' && p0.eid) {
1644
1525
  const details = await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(String(p0.eid))}`);
1645
1526
  p = Object.assign({}, p0, details, {
1646
- // keep backward compat with older field names used by templates below
1527
+ pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1528
+ pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1529
+ pickupLon: details.lon || details.pickupLon || p0.pickupLon,
1530
+ });
1531
+ } else if (p0.type === 'outing' && p0.oid) {
1532
+ const details = await fetchJson(`/api/v3/plugins/calendar-onekite/outings/${encodeURIComponent(String(p0.oid))}`);
1533
+ p = Object.assign({}, p0, details, {
1647
1534
  pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1648
1535
  pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1649
1536
  pickupLon: details.lon || details.pickupLon || p0.pickupLon,
@@ -1656,55 +1543,106 @@ function toDatetimeLocalValue(date) {
1656
1543
 
1657
1544
  try {
1658
1545
  if (p.type === 'special') {
1659
- const specialKind = String((p.kind || p0.kind || 'event'));
1660
- const specialLabel = (specialKind === 'outing') ? 'Sortie' : 'Évènement';
1661
- const username = String(p.username || '').trim();
1662
- const userLine = username
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>`
1664
- : '';
1665
- const addr = String(p.pickupAddress || '').trim();
1666
- const lat = Number(p.pickupLat);
1667
- const lon = Number(p.pickupLon);
1668
- const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1669
- const notes = String(p.notes || '').trim();
1670
- const addrHtml = addr
1671
- ? (hasCoords
1672
- ? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
1673
- : `${escapeHtml(addr)}`)
1674
- : '';
1675
- const html = `
1676
- <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
1677
- ${userLine}
1678
- <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} ${escapeHtml(formatDtWithTime(ev.end))}</div>
1679
- ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
1680
- ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
1681
- `;
1682
- const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
1683
- bootbox.dialog({
1684
- title: specialLabel,
1685
- message: html,
1686
- buttons: {
1687
- close: { label: 'Fermer', className: 'btn-secondary' },
1688
- ...(canDel ? {
1689
- del: {
1690
- label: 'Supprimer',
1691
- className: 'btn-danger',
1692
- callback: async () => {
1693
- try {
1694
- const eid = String(p.eid || ev.id).replace(/^special:/, '');
1695
- await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' });
1696
- showAlert('success', `${specialLabel} supprimé.`);
1697
- calendar.refetchEvents();
1698
- } catch (e) {
1699
- showAlert('error', 'Suppression impossible.');
1700
- }
1546
+ const username = String(p.username || '').trim();
1547
+ const userLine = username
1548
+ ? `<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>`
1549
+ : '';
1550
+ const addr = String(p.pickupAddress || p.address || '').trim();
1551
+ const lat = Number(p.pickupLat || p.lat);
1552
+ const lon = Number(p.pickupLon || p.lon);
1553
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1554
+ const notes = String(p.notes || '').trim();
1555
+ const addrHtml = addr
1556
+ ? (hasCoords
1557
+ ? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
1558
+ : `${escapeHtml(addr)}`)
1559
+ : '';
1560
+ const html = `
1561
+ <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
1562
+ ${userLine}
1563
+ <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} ${escapeHtml(formatDtWithTime(ev.end))}</div>
1564
+ ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
1565
+ ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
1566
+ `;
1567
+ const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
1568
+ bootbox.dialog({
1569
+ title: 'Évènement',
1570
+ message: html,
1571
+ buttons: {
1572
+ close: { label: 'Fermer', className: 'btn-secondary' },
1573
+ ...(canDel ? {
1574
+ del: {
1575
+ label: 'Supprimer',
1576
+ className: 'btn-danger',
1577
+ callback: async () => {
1578
+ try {
1579
+ const eid = String(p.eid || ev.id).replace(/^special:/, '');
1580
+ await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' });
1581
+ showAlert('success', 'Évènement supprimé.');
1582
+ calendar.refetchEvents();
1583
+ } catch (e) {
1584
+ showAlert('error', 'Suppression impossible.');
1585
+ }
1586
+ },
1701
1587
  },
1702
- },
1703
- } : {}),
1704
- },
1705
- });
1706
- return;
1588
+ } : {}),
1589
+ },
1590
+ });
1591
+ return;
1592
+ }
1593
+
1594
+ if (p.type === 'outing') {
1595
+ const username = String(p.username || '').trim();
1596
+ const userLine = username
1597
+ ? `<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>`
1598
+ : '';
1599
+ const addr = String(p.address || p.pickupAddress || '').trim();
1600
+ const lat = Number(p.lat || p.pickupLat);
1601
+ const lon = Number(p.lon || p.pickupLon);
1602
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1603
+ const notes = String(p.notes || '').trim();
1604
+ const addrHtml = addr
1605
+ ? (hasCoords
1606
+ ? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
1607
+ : `${escapeHtml(addr)}`)
1608
+ : '';
1609
+ const html = `
1610
+ <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
1611
+ ${userLine}
1612
+ <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
1613
+ ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
1614
+ ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
1615
+ `;
1616
+ const canDel = !!(p.canDeleteOuting);
1617
+ bootbox.dialog({
1618
+ title: 'Prévision de sortie',
1619
+ message: html,
1620
+ buttons: {
1621
+ close: { label: 'Fermer', className: 'btn-secondary' },
1622
+ ...(canDel ? {
1623
+ del: {
1624
+ label: 'Annuler',
1625
+ className: 'btn-danger',
1626
+ callback: async () => {
1627
+ try {
1628
+ const oid = String(p.oid || ev.id).replace(/^outing:/, '');
1629
+ await fetchJson(`/api/v3/plugins/calendar-onekite/outings/${encodeURIComponent(oid)}`, { method: 'DELETE' });
1630
+ showAlert('success', 'Prévision annulée.');
1631
+ calendar.refetchEvents();
1632
+ } catch (e) {
1633
+ showAlert('error', 'Annulation impossible.');
1634
+ }
1635
+ },
1636
+ },
1637
+ } : {}),
1638
+ },
1639
+ });
1640
+ return;
1641
+ }
1642
+ } catch (e) {
1643
+ // ignore
1707
1644
  }
1645
+
1708
1646
  const rid = p.rid || ev.id;
1709
1647
  const status = p.status || '';
1710
1648
 
@@ -2016,9 +1954,6 @@ function toDatetimeLocalValue(date) {
2016
1954
  message: baseHtml,
2017
1955
  buttons,
2018
1956
  });
2019
- } finally {
2020
- isDialogOpen = false;
2021
- }
2022
1957
  },
2023
1958
  });
2024
1959
 
@@ -2150,7 +2085,7 @@ function toDatetimeLocalValue(date) {
2150
2085
  }
2151
2086
 
2152
2087
 
2153
- async function openFabDatePicker() {
2088
+ async function openFabDatePicker(onSelection) {
2154
2089
  if (!lockAction('fab-date-picker', 700)) return;
2155
2090
 
2156
2091
  // Cannot book past dates (mobile FAB). Same-day booking is allowed.
@@ -2208,44 +2143,16 @@ async function openFabDatePicker() {
2208
2143
  const endExcl = new Date(e);
2209
2144
  endExcl.setDate(endExcl.getDate() + 1);
2210
2145
 
2146
+ // If a callback is provided, delegate the next step (chooser).
2147
+ if (typeof onSelection === 'function') {
2148
+ try { onSelection({ start: s, end: endExcl, allDay: true }); } catch (e) {}
2149
+ return true;
2150
+ }
2151
+
2211
2152
  (async () => {
2212
2153
  try {
2213
2154
  if (isDialogOpen) return;
2214
2155
  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
-
2249
2156
  const items = cachedItems || (await loadItems());
2250
2157
  const chosen = await openReservationDialog({ start: s, end: endExcl }, items);
2251
2158
  if (chosen && chosen.itemIds && chosen.itemIds.length) {
@@ -2268,8 +2175,6 @@ async function openFabDatePicker() {
2268
2175
  }
2269
2176
  } catch (err) {
2270
2177
  // ignore
2271
- } finally {
2272
- isDialogOpen = false;
2273
2178
  }
2274
2179
  })();
2275
2180
 
@@ -2405,6 +2310,33 @@ async function openFabDatePicker() {
2405
2310
  render();
2406
2311
  });
2407
2312
  }
2313
+
2314
+ // Swipe left/right to change month
2315
+ try {
2316
+ const root = document.getElementById('onekite-range-picker');
2317
+ if (root) {
2318
+ let x0 = null;
2319
+ root.addEventListener('touchstart', (ev) => {
2320
+ try { x0 = ev.touches && ev.touches[0] ? ev.touches[0].clientX : null; } catch (e) { x0 = null; }
2321
+ }, { passive: true });
2322
+ root.addEventListener('touchend', (ev) => {
2323
+ try {
2324
+ if (x0 === null) return;
2325
+ const x1 = ev.changedTouches && ev.changedTouches[0] ? ev.changedTouches[0].clientX : null;
2326
+ if (x1 === null) return;
2327
+ const dx = x1 - x0;
2328
+ if (Math.abs(dx) < 40) return;
2329
+ if (dx < 0) {
2330
+ state.cursor.setMonth(state.cursor.getMonth() + 1);
2331
+ } else {
2332
+ state.cursor.setMonth(state.cursor.getMonth() - 1);
2333
+ }
2334
+ render();
2335
+ } catch (e) {}
2336
+ finally { x0 = null; }
2337
+ }, { passive: true });
2338
+ }
2339
+ } catch (e) {}
2408
2340
  render();
2409
2341
  } catch (e) {}
2410
2342
  });
@@ -2436,7 +2368,7 @@ function parseYmdDate(ymdStr) {
2436
2368
  fabEl.setAttribute('aria-label', 'Nouvelle réservation');
2437
2369
  fabEl.innerHTML = '<i class="fa fa-plus"></i>';
2438
2370
 
2439
- fabHandler = () => openFabDatePicker();
2371
+ fabHandler = () => openFabDatePicker(handleCreateFromSelection);
2440
2372
  fabEl.addEventListener('click', fabHandler);
2441
2373
 
2442
2374
  document.body.appendChild(fabEl);