1/** 2 * DokuWiki Compact Calendar Plugin JavaScript 3 * Loaded independently to avoid DokuWiki concatenation issues 4 * @version 7.0.8 5 */ 6 7// Debug mode - set to true for console logging 8var CALENDAR_DEBUG = false; 9 10// Debug logging helper 11function calendarLog() { 12 if (CALENDAR_DEBUG && console && console.log) { 13 console.log.apply(console, ['[Calendar]'].concat(Array.prototype.slice.call(arguments))); 14 } 15} 16 17function calendarError() { 18 if (console && console.error) { 19 console.error.apply(console, ['[Calendar]'].concat(Array.prototype.slice.call(arguments))); 20 } 21} 22 23/** 24 * Format a Date object as YYYY-MM-DD in LOCAL time (not UTC) 25 * This avoids timezone issues where toISOString() shifts dates 26 * For example: In Prague (UTC+1), midnight local = 23:00 UTC previous day 27 * @param {Date} date - Date object to format 28 * @returns {string} Date string in YYYY-MM-DD format 29 */ 30function formatLocalDate(date) { 31 var year = date.getFullYear(); 32 var month = String(date.getMonth() + 1).padStart(2, '0'); 33 var day = String(date.getDate()).padStart(2, '0'); 34 return year + '-' + month + '-' + day; 35} 36 37// Ensure DOKU_BASE is defined - check multiple sources 38if (typeof DOKU_BASE === 'undefined') { 39 // Try to get from global jsinfo object (DokuWiki standard) 40 if (typeof window.jsinfo !== 'undefined' && window.jsinfo.dokubase) { 41 window.DOKU_BASE = window.jsinfo.dokubase; 42 } else { 43 // Fallback: extract from script source path 44 var scripts = document.getElementsByTagName('script'); 45 var pluginScriptPath = null; 46 for (var i = 0; i < scripts.length; i++) { 47 if (scripts[i].src && scripts[i].src.indexOf('calendar/script.js') !== -1) { 48 pluginScriptPath = scripts[i].src; 49 break; 50 } 51 } 52 53 if (pluginScriptPath) { 54 // Extract base path from: .../lib/plugins/calendar/script.js 55 var match = pluginScriptPath.match(/^(.*?)lib\/plugins\//); 56 window.DOKU_BASE = match ? match[1] : '/'; 57 } else { 58 // Last resort: use root 59 window.DOKU_BASE = '/'; 60 } 61 } 62} 63 64// Shorthand for convenience 65var DOKU_BASE = window.DOKU_BASE || '/'; 66 67/** 68 * Get DokuWiki security token from multiple possible sources 69 * DokuWiki stores this in different places depending on version/config 70 */ 71function getSecurityToken() { 72 // Try JSINFO.sectok (standard location) 73 if (typeof JSINFO !== 'undefined' && JSINFO.sectok) { 74 return JSINFO.sectok; 75 } 76 // Try window.JSINFO 77 if (typeof window.JSINFO !== 'undefined' && window.JSINFO.sectok) { 78 return window.JSINFO.sectok; 79 } 80 // Try finding it in a hidden form field (some templates/plugins add this) 81 var sectokInput = document.querySelector('input[name="sectok"]'); 82 if (sectokInput && sectokInput.value) { 83 return sectokInput.value; 84 } 85 // Try meta tag (some DokuWiki setups) 86 var sectokMeta = document.querySelector('meta[name="sectok"]'); 87 if (sectokMeta && sectokMeta.content) { 88 return sectokMeta.content; 89 } 90 // Return empty string if not found 91 console.warn('Calendar plugin: Security token not found'); 92 return ''; 93} 94 95// Helper: propagate CSS variables from a calendar container to a target element 96// This is needed for dialogs/popups that use position:fixed (they inherit CSS vars 97// from DOM parents per spec, but some DokuWiki templates break this inheritance) 98function propagateThemeVars(calId, targetEl) { 99 if (!targetEl) return; 100 // Find the calendar container (could be cal_, panel_, sidebar-widget-, etc.) 101 const container = document.getElementById(calId) 102 || document.getElementById('sidebar-widget-' + calId) 103 || document.querySelector('[id$="' + calId + '"]'); 104 if (!container) return; 105 const cs = getComputedStyle(container); 106 const vars = [ 107 '--background-site', '--background-alt', '--background-header', 108 '--text-primary', '--text-bright', '--text-dim', 109 '--border-color', '--border-main', 110 '--cell-bg', '--cell-today-bg', '--grid-bg', 111 '--shadow-color', '--header-border', '--header-shadow', 112 '--btn-text' 113 ]; 114 vars.forEach(v => { 115 const val = cs.getPropertyValue(v).trim(); 116 if (val) targetEl.style.setProperty(v, val); 117 }); 118} 119 120// Filter calendar by namespace 121window.filterCalendarByNamespace = function(calId, namespace) { 122 // Get current year and month from calendar 123 const container = document.getElementById(calId); 124 if (!container) { 125 console.error('Calendar container not found:', calId); 126 return; 127 } 128 129 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 130 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 131 132 // Reload calendar with the filtered namespace 133 navCalendar(calId, year, month, namespace); 134}; 135 136// Navigate to different month 137window.navCalendar = function(calId, year, month, namespace) { 138 139 // Read exclude list from container data attribute 140 const container = document.getElementById(calId); 141 const exclude = container ? (container.dataset.exclude || '') : ''; 142 143 const params = new URLSearchParams({ 144 call: 'plugin_calendar', 145 action: 'load_month', 146 year: year, 147 month: month, 148 namespace: namespace, 149 exclude: exclude, 150 _: new Date().getTime() // Cache buster 151 }); 152 153 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 154 method: 'POST', 155 headers: { 156 'Content-Type': 'application/x-www-form-urlencoded', 157 'Cache-Control': 'no-cache, no-store, must-revalidate', 158 'Pragma': 'no-cache' 159 }, 160 body: params.toString() 161 }) 162 .then(r => r.json()) 163 .then(data => { 164 if (data.success) { 165 rebuildCalendar(calId, data.year, data.month, data.events, namespace); 166 } else { 167 console.error('Failed to load month:', data.error); 168 } 169 }) 170 .catch(err => { 171 console.error('Error loading month:', err); 172 }); 173}; 174 175// Jump to current month 176window.jumpToToday = function(calId, namespace) { 177 const today = new Date(); 178 const year = today.getFullYear(); 179 const month = today.getMonth() + 1; // JavaScript months are 0-indexed 180 navCalendar(calId, year, month, namespace); 181}; 182 183// Jump to today for event panel 184window.jumpTodayPanel = function(calId, namespace) { 185 const today = new Date(); 186 const year = today.getFullYear(); 187 const month = today.getMonth() + 1; 188 navEventPanel(calId, year, month, namespace); 189}; 190 191// Open month picker dialog 192window.openMonthPicker = function(calId, currentYear, currentMonth, namespace) { 193 194 const overlay = document.getElementById('month-picker-overlay-' + calId); 195 196 const monthSelect = document.getElementById('month-picker-month-' + calId); 197 198 const yearSelect = document.getElementById('month-picker-year-' + calId); 199 200 if (!overlay) { 201 console.error('Month picker overlay not found! ID:', 'month-picker-overlay-' + calId); 202 return; 203 } 204 205 if (!monthSelect || !yearSelect) { 206 console.error('Select elements not found!'); 207 return; 208 } 209 210 // Set current values 211 monthSelect.value = currentMonth; 212 yearSelect.value = currentYear; 213 214 // Show overlay 215 overlay.style.display = 'flex'; 216}; 217 218// Open month picker dialog for event panel 219window.openMonthPickerPanel = function(calId, currentYear, currentMonth, namespace) { 220 openMonthPicker(calId, currentYear, currentMonth, namespace); 221}; 222 223// Close month picker dialog 224window.closeMonthPicker = function(calId) { 225 const overlay = document.getElementById('month-picker-overlay-' + calId); 226 overlay.style.display = 'none'; 227}; 228 229// Jump to selected month 230window.jumpToSelectedMonth = function(calId, namespace) { 231 const monthSelect = document.getElementById('month-picker-month-' + calId); 232 const yearSelect = document.getElementById('month-picker-year-' + calId); 233 234 const month = parseInt(monthSelect.value); 235 const year = parseInt(yearSelect.value); 236 237 closeMonthPicker(calId); 238 239 // Check if this is a calendar or event panel 240 const container = document.getElementById(calId); 241 if (container && container.classList.contains('event-panel-standalone')) { 242 navEventPanel(calId, year, month, namespace); 243 } else { 244 navCalendar(calId, year, month, namespace); 245 } 246}; 247 248// Rebuild calendar grid after navigation 249window.rebuildCalendar = function(calId, year, month, events, namespace) { 250 251 const container = document.getElementById(calId); 252 const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 253 'July', 'August', 'September', 'October', 'November', 'December']; 254 255 // Get theme data from container 256 const theme = container.dataset.theme || 'matrix'; 257 let themeStyles = {}; 258 try { 259 themeStyles = JSON.parse(container.dataset.themeStyles || '{}'); 260 } catch (e) { 261 console.error('Failed to parse theme styles:', e); 262 themeStyles = {}; 263 } 264 265 // Preserve original namespace if not yet set 266 if (!container.dataset.originalNamespace) { 267 container.setAttribute('data-original-namespace', namespace || ''); 268 } 269 270 // Update container data attributes for current month/year 271 container.setAttribute('data-year', year); 272 container.setAttribute('data-month', month); 273 274 // Update embedded events data 275 let eventsDataEl = document.getElementById('events-data-' + calId); 276 if (eventsDataEl) { 277 eventsDataEl.textContent = JSON.stringify(events); 278 } else { 279 eventsDataEl = document.createElement('script'); 280 eventsDataEl.type = 'application/json'; 281 eventsDataEl.id = 'events-data-' + calId; 282 eventsDataEl.textContent = JSON.stringify(events); 283 container.appendChild(eventsDataEl); 284 } 285 286 // Update header 287 const header = container.querySelector('.calendar-compact-header h3'); 288 header.textContent = monthNames[month - 1] + ' ' + year; 289 290 // Update or create namespace filter indicator 291 let filterIndicator = container.querySelector('.calendar-namespace-filter'); 292 const shouldShowFilter = namespace && namespace !== '' && namespace !== '*' && 293 namespace.indexOf('*') === -1 && namespace.indexOf(';') === -1; 294 295 if (shouldShowFilter) { 296 // Show/update filter indicator 297 if (!filterIndicator) { 298 // Create filter indicator if it doesn't exist 299 const headerDiv = container.querySelector('.calendar-compact-header'); 300 if (headerDiv) { 301 filterIndicator = document.createElement('div'); 302 filterIndicator.className = 'calendar-namespace-filter'; 303 filterIndicator.id = 'namespace-filter-' + calId; 304 headerDiv.parentNode.insertBefore(filterIndicator, headerDiv.nextSibling); 305 } 306 } 307 308 if (filterIndicator) { 309 filterIndicator.innerHTML = 310 '<span class="namespace-filter-label">Filtering:</span>' + 311 '<span class="namespace-filter-name">' + escapeHtml(namespace) + '</span>' + 312 '<button class="namespace-filter-clear" onclick="clearNamespaceFilter(\'' + calId + '\')" title="Clear filter and show all namespaces">✕</button>'; 313 filterIndicator.style.display = 'flex'; 314 } 315 } else { 316 // Hide filter indicator 317 if (filterIndicator) { 318 filterIndicator.style.display = 'none'; 319 } 320 } 321 322 // Update container's namespace attribute 323 container.setAttribute('data-namespace', namespace || ''); 324 325 // Update nav buttons 326 let prevMonth = month - 1; 327 let prevYear = year; 328 if (prevMonth < 1) { 329 prevMonth = 12; 330 prevYear--; 331 } 332 333 let nextMonth = month + 1; 334 let nextYear = year; 335 if (nextMonth > 12) { 336 nextMonth = 1; 337 nextYear++; 338 } 339 340 const navBtns = container.querySelectorAll('.cal-nav-btn'); 341 navBtns[0].setAttribute('onclick', `navCalendar('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 342 navBtns[1].setAttribute('onclick', `navCalendar('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 343 344 // Rebuild calendar grid 345 const tbody = container.querySelector('.calendar-compact-grid tbody'); 346 const firstDay = new Date(year, month - 1, 1); 347 const daysInMonth = new Date(year, month, 0).getDate(); 348 const dayOfWeek = firstDay.getDay(); 349 350 // Calculate month boundaries 351 const monthStart = new Date(year, month - 1, 1); 352 const monthEnd = new Date(year, month - 1, daysInMonth); 353 354 // Build a map of all events with their date ranges 355 const eventRanges = {}; 356 for (const [dateKey, dayEvents] of Object.entries(events)) { 357 // Defensive check: ensure dayEvents is an array 358 if (!Array.isArray(dayEvents)) { 359 console.error('dayEvents is not an array for dateKey:', dateKey, 'value:', dayEvents); 360 continue; 361 } 362 363 // Only process events that could possibly overlap with this month/year 364 const dateYear = parseInt(dateKey.split('-')[0]); 365 366 // Skip events from completely different years (unless they're very long multi-day events) 367 if (Math.abs(dateYear - year) > 1) { 368 continue; 369 } 370 371 for (const evt of dayEvents) { 372 const startDate = dateKey; 373 const endDate = evt.endDate || dateKey; 374 375 // Check if event overlaps with current month 376 const eventStart = new Date(startDate + 'T00:00:00'); 377 const eventEnd = new Date(endDate + 'T00:00:00'); 378 379 // Skip if event doesn't overlap with current month 380 if (eventEnd < monthStart || eventStart > monthEnd) { 381 continue; 382 } 383 384 // Create entry for each day the event spans 385 const start = new Date(startDate + 'T00:00:00'); 386 const end = new Date(endDate + 'T00:00:00'); 387 const current = new Date(start); 388 389 while (current <= end) { 390 // Use formatLocalDate to avoid timezone shift issues 391 const currentKey = formatLocalDate(current); 392 393 // Check if this date is in current month (use current Date object directly) 394 if (current.getFullYear() === year && current.getMonth() === month - 1) { 395 if (!eventRanges[currentKey]) { 396 eventRanges[currentKey] = []; 397 } 398 399 // Add event with span information 400 const eventCopy = {...evt}; 401 eventCopy._span_start = startDate; 402 eventCopy._span_end = endDate; 403 eventCopy._is_first_day = (currentKey === startDate); 404 eventCopy._is_last_day = (currentKey === endDate); 405 eventCopy._original_date = dateKey; 406 407 // Check if event continues from previous month or to next month 408 eventCopy._continues_from_prev = (eventStart < monthStart); 409 eventCopy._continues_to_next = (eventEnd > monthEnd); 410 411 eventRanges[currentKey].push(eventCopy); 412 } 413 414 current.setDate(current.getDate() + 1); 415 } 416 } 417 } 418 419 // Assign stable row slots to multi-day events so bars line up across days 420 // Each multi-day event gets a consistent slot index across all days it spans 421 const slotAssignments = {}; // dateKey -> array of {event, slot} 422 const eventSlots = {}; // eventId -> assigned slot number 423 424 // First pass: identify all multi-day events and assign them stable slots 425 // Process dates in order so slots are assigned left-to-right 426 const allDates = Object.keys(eventRanges).sort(); 427 428 for (const dateKey of allDates) { 429 const dayEvents = eventRanges[dateKey]; 430 if (!dayEvents) continue; 431 432 for (const evt of dayEvents) { 433 const eid = evt.id || evt.title; 434 const isMultiDay = evt._span_start !== evt._span_end; 435 436 if (isMultiDay && !(eid in eventSlots)) { 437 // Find the lowest available slot across ALL days this event spans 438 let slot = 0; 439 let slotFree = false; 440 while (!slotFree) { 441 slotFree = true; 442 // Check every day this event spans to see if this slot is taken 443 const checkStart = new Date(evt._span_start + 'T00:00:00'); 444 const checkEnd = new Date(evt._span_end + 'T00:00:00'); 445 const checkCurrent = new Date(checkStart); 446 while (checkCurrent <= checkEnd) { 447 const checkKey = formatLocalDate(checkCurrent); 448 if (slotAssignments[checkKey]) { 449 for (const assigned of slotAssignments[checkKey]) { 450 if (assigned.slot === slot) { 451 slotFree = false; 452 break; 453 } 454 } 455 } 456 if (!slotFree) break; 457 checkCurrent.setDate(checkCurrent.getDate() + 1); 458 } 459 if (!slotFree) slot++; 460 } 461 eventSlots[eid] = slot; 462 463 // Reserve this slot on all days the event spans 464 const resStart = new Date(evt._span_start + 'T00:00:00'); 465 const resEnd = new Date(evt._span_end + 'T00:00:00'); 466 const resCurrent = new Date(resStart); 467 while (resCurrent <= resEnd) { 468 const resKey = formatLocalDate(resCurrent); 469 if (!slotAssignments[resKey]) slotAssignments[resKey] = []; 470 slotAssignments[resKey].push({ id: eid, slot: slot }); 471 resCurrent.setDate(resCurrent.getDate() + 1); 472 } 473 } 474 } 475 } 476 477 // Second pass: assign slots to single-day events 478 for (const dateKey of allDates) { 479 const dayEvents = eventRanges[dateKey]; 480 if (!dayEvents) continue; 481 482 // Sort single-day events by time 483 const singleDay = dayEvents.filter(e => { 484 const eid = e.id || e.title; 485 return !(eid in eventSlots); 486 }).sort((a, b) => { 487 const timeA = a.time || ''; 488 const timeB = b.time || ''; 489 if (!timeA && timeB) return -1; 490 if (timeA && !timeB) return 1; 491 return timeA.localeCompare(timeB); 492 }); 493 494 for (const evt of singleDay) { 495 const eid = evt.id || evt.title; 496 let slot = 0; 497 while (true) { 498 const taken = (slotAssignments[dateKey] || []).some(a => a.slot === slot); 499 if (!taken) break; 500 slot++; 501 } 502 eventSlots[eid] = slot; 503 if (!slotAssignments[dateKey]) slotAssignments[dateKey] = []; 504 slotAssignments[dateKey].push({ id: eid, slot: slot }); 505 } 506 } 507 508 let html = ''; 509 let currentDay = 1; 510 const rowCount = Math.ceil((daysInMonth + dayOfWeek) / 7); 511 512 for (let row = 0; row < rowCount; row++) { 513 html += '<tr>'; 514 for (let col = 0; col < 7; col++) { 515 if ((row === 0 && col < dayOfWeek) || currentDay > daysInMonth) { 516 html += `<td class="cal-empty"></td>`; 517 } else { 518 const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(currentDay).padStart(2, '0')}`; 519 520 // Get today's date in local timezone 521 const todayObj = new Date(); 522 const today = `${todayObj.getFullYear()}-${String(todayObj.getMonth() + 1).padStart(2, '0')}-${String(todayObj.getDate()).padStart(2, '0')}`; 523 524 const isToday = dateKey === today; 525 const hasEvents = eventRanges[dateKey] && eventRanges[dateKey].length > 0; 526 527 let classes = 'cal-day'; 528 if (isToday) classes += ' cal-today'; 529 if (hasEvents) classes += ' cal-has-events'; 530 531 const dayNumClass = isToday ? 'day-num day-num-today' : 'day-num'; 532 533 html += `<td class="${classes}" data-date="${dateKey}" onclick="showDayPopup('${calId}', '${dateKey}', '${namespace}')">`; 534 html += `<span class="${dayNumClass}">${currentDay}</span>`; 535 536 if (hasEvents) { 537 // Get important namespaces 538 let importantNamespaces = ['important']; 539 if (container.dataset.importantNamespaces) { 540 try { 541 importantNamespaces = JSON.parse(container.dataset.importantNamespaces); 542 } catch (e) {} 543 } 544 545 // Build slot-ordered event list with placeholders for gaps 546 const daySlots = (slotAssignments[dateKey] || []).slice().sort((a, b) => a.slot - b.slot); 547 const maxSlot = daySlots.length > 0 ? daySlots[daySlots.length - 1].slot : -1; 548 549 // Create a map from slot -> event 550 const slotMap = {}; 551 for (const evt of eventRanges[dateKey]) { 552 const eid = evt.id || evt.title; 553 const slot = eventSlots[eid]; 554 if (slot !== undefined) slotMap[slot] = evt; 555 } 556 557 // Show colored stacked bars for each slot (with spacers for empty slots) 558 html += '<div class="event-indicators">'; 559 for (let s = 0; s <= maxSlot; s++) { 560 const evt = slotMap[s]; 561 if (!evt) { 562 // Empty spacer to maintain alignment 563 html += '<span style="display:block;width:100%;height:6px;min-height:6px;flex-shrink:0;"></span>'; 564 continue; 565 } 566 567 const eventId = evt.id || ''; 568 const eventColor = evt.color || '#3498db'; 569 const eventTitle = evt.title || 'Event'; 570 const eventTime = evt.time || ''; 571 const originalDate = evt._original_date || dateKey; 572 const isFirstDay = evt._is_first_day !== undefined ? evt._is_first_day : true; 573 const isLastDay = evt._is_last_day !== undefined ? evt._is_last_day : true; 574 575 // Check if important namespace 576 let evtNs = evt.namespace || evt._namespace || ''; 577 let isImportant = false; 578 for (const impNs of importantNamespaces) { 579 if (evtNs === impNs || evtNs.startsWith(impNs + ':')) { 580 isImportant = true; 581 break; 582 } 583 } 584 585 let barClass = !eventTime ? 'event-bar-no-time' : 'event-bar-timed'; 586 if (!isFirstDay) barClass += ' event-bar-continues'; 587 if (!isLastDay) barClass += ' event-bar-continuing'; 588 if (isImportant) { 589 barClass += ' event-bar-important'; 590 if (isFirstDay) { 591 barClass += ' event-bar-has-star'; 592 } 593 } 594 595 html += `<span class="event-bar ${barClass}" `; 596 html += `style="background: ${eventColor};" `; 597 html += `title="${isImportant ? '⭐ ' : ''}${escapeHtml(eventTitle)}${eventTime ? ' @ ' + eventTime : ''}" `; 598 html += `onclick="event.stopPropagation(); highlightEvent('${calId}', '${eventId}', '${originalDate}');">`; 599 html += '</span>'; 600 } 601 html += '</div>'; 602 } 603 604 html += '</td>'; 605 currentDay++; 606 } 607 } 608 html += '</tr>'; 609 } 610 611 tbody.innerHTML = html; 612 613 // Update Today button with current namespace 614 const todayBtn = container.querySelector('.cal-today-btn'); 615 if (todayBtn) { 616 todayBtn.setAttribute('onclick', `jumpToToday('${calId}', '${namespace}')`); 617 } 618 619 // Update month picker with current namespace 620 const monthPicker = container.querySelector('.calendar-month-picker'); 621 if (monthPicker) { 622 monthPicker.setAttribute('onclick', `openMonthPicker('${calId}', ${year}, ${month}, '${namespace}')`); 623 } 624 625 // Rebuild event list - server already filtered to current month 626 const eventList = container.querySelector('.event-list-compact'); 627 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 628 629 // Auto-scroll to first future event (past events will be above viewport) 630 setTimeout(() => { 631 const firstFuture = eventList.querySelector('[data-first-future="true"]'); 632 if (firstFuture) { 633 firstFuture.scrollIntoView({ behavior: 'smooth', block: 'start' }); 634 } 635 }, 100); 636 637 // Update title 638 const title = container.querySelector('#eventlist-title-' + calId); 639 title.textContent = 'Events'; 640}; 641 642// Render event list from data 643window.renderEventListFromData = function(events, calId, namespace, year, month) { 644 if (!events || Object.keys(events).length === 0) { 645 return '<p class="no-events-msg">No events this month</p>'; 646 } 647 648 // Get theme data from container 649 const container = document.getElementById(calId); 650 let themeStyles = {}; 651 if (container && container.dataset.themeStyles) { 652 try { 653 themeStyles = JSON.parse(container.dataset.themeStyles); 654 } catch (e) { 655 console.error('Failed to parse theme styles in renderEventListFromData:', e); 656 } 657 } 658 659 // Check for time conflicts 660 events = checkTimeConflicts(events, null); 661 662 let pastHtml = ''; 663 let futureHtml = ''; 664 let pastCount = 0; 665 666 const sortedDates = Object.keys(events).sort(); 667 const today = new Date(); 668 today.setHours(0, 0, 0, 0); 669 const todayStr = formatLocalDate(today); 670 671 // Helper function to check if event is past (with 15-minute grace period) 672 // For multi-day events, uses the end date instead of start date 673 const isEventPast = function(dateKey, time, endDate) { 674 // For multi-day events, use the end date to determine if past 675 const effectiveDate = endDate || dateKey; 676 677 // If the effective end date is past, the event is past 678 if (effectiveDate < todayStr) { 679 return true; 680 } 681 682 // If the effective end date is in the future, event is still ongoing 683 if (effectiveDate > todayStr) { 684 return false; 685 } 686 687 // Event ends today - check time with grace period 688 if (time && time.trim() !== '') { 689 try { 690 const now = new Date(); 691 const eventDateTime = new Date(effectiveDate + 'T' + time); 692 693 // Add 15-minute grace period 694 const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000); 695 696 // Event is past if current time > event time + 15 minutes 697 return now > gracePeriodEnd; 698 } catch (e) { 699 // If time parsing fails, treat as future 700 return false; 701 } 702 } 703 704 // No time specified for today's event, treat as future 705 return false; 706 }; 707 708 // Filter events to only current month if year/month provided 709 const monthStart = year && month ? new Date(year, month - 1, 1) : null; 710 const monthEnd = year && month ? new Date(year, month, 0, 23, 59, 59) : null; 711 712 for (const dateKey of sortedDates) { 713 // Skip events not in current month if filtering 714 if (monthStart && monthEnd) { 715 const eventDate = new Date(dateKey + 'T00:00:00'); 716 717 if (eventDate < monthStart || eventDate > monthEnd) { 718 continue; 719 } 720 } 721 722 // Sort events within this day by time (all-day events at top) 723 const dayEvents = events[dateKey]; 724 dayEvents.sort((a, b) => { 725 const timeA = a.time && a.time.trim() !== '' ? a.time : null; 726 const timeB = b.time && b.time.trim() !== '' ? b.time : null; 727 728 // All-day events (no time) go to the TOP 729 if (timeA === null && timeB !== null) return -1; // A before B 730 if (timeA !== null && timeB === null) return 1; // A after B 731 if (timeA === null && timeB === null) return 0; // Both all-day, equal 732 733 // Both have times, sort chronologically 734 return timeA.localeCompare(timeB); 735 }); 736 737 for (const event of dayEvents) { 738 const isTask = event.isTask || false; 739 const completed = event.completed || false; 740 741 // Use helper function to determine if event is past (with grace period) 742 const isPast = isEventPast(dateKey, event.time, event.endDate); 743 const isPastDue = isPast && isTask && !completed; 744 745 // Determine if this goes in past section 746 const isPastOrCompleted = (isPast && (!isTask || completed)) || completed; 747 748 const eventHtml = renderEventItem(event, dateKey, calId, namespace); 749 750 if (isPastOrCompleted) { 751 pastCount++; 752 pastHtml += eventHtml; 753 } else { 754 futureHtml += eventHtml; 755 } 756 } 757 } 758 759 let html = ''; 760 761 // Add collapsible past events section if any exist 762 if (pastCount > 0) { 763 html += '<div class="past-events-section">'; 764 html += '<div class="past-events-toggle" onclick="togglePastEvents(\'' + calId + '\')">'; 765 html += '<span class="past-events-arrow" id="past-arrow-' + calId + '">▶</span> '; 766 html += '<span class="past-events-label">Past Events (' + pastCount + ')</span>'; 767 html += '</div>'; 768 html += '<div class="past-events-content" id="past-events-' + calId + '" style="display:none;">'; 769 html += pastHtml; 770 html += '</div>'; 771 html += '</div>'; 772 } else { 773 } 774 775 // Add future events 776 html += futureHtml; 777 778 779 if (!html) { 780 return '<p class="no-events-msg">No events this month</p>'; 781 } 782 783 return html; 784}; 785 786// Show day popup with events when clicking a date 787window.showDayPopup = function(calId, date, namespace) { 788 // Get events for this calendar 789 const eventsDataEl = document.getElementById('events-data-' + calId); 790 let events = {}; 791 792 if (eventsDataEl) { 793 try { 794 events = JSON.parse(eventsDataEl.textContent); 795 } catch (e) { 796 console.error('Failed to parse events data:', e); 797 } 798 } 799 800 const dayEvents = events[date] || []; 801 802 // Check for conflicts on this day 803 const dayEventsObj = {[date]: dayEvents}; 804 const checkedEvents = checkTimeConflicts(dayEventsObj, null); 805 const dayEventsWithConflicts = checkedEvents[date] || dayEvents; 806 807 // Sort events: all-day at top, then chronological by time 808 dayEventsWithConflicts.sort((a, b) => { 809 const timeA = a.time && a.time.trim() !== '' ? a.time : null; 810 const timeB = b.time && b.time.trim() !== '' ? b.time : null; 811 812 // All-day events (no time) go to the TOP 813 if (timeA === null && timeB !== null) return -1; // A before B 814 if (timeA !== null && timeB === null) return 1; // A after B 815 if (timeA === null && timeB === null) return 0; // Both all-day, equal 816 817 // Both have times, sort chronologically 818 return timeA.localeCompare(timeB); 819 }); 820 821 const dateObj = new Date(date + 'T00:00:00'); 822 const displayDate = dateObj.toLocaleDateString('en-US', { 823 weekday: 'long', 824 month: 'long', 825 day: 'numeric', 826 year: 'numeric' 827 }); 828 829 // Create popup 830 let popup = document.getElementById('day-popup-' + calId); 831 if (!popup) { 832 popup = document.createElement('div'); 833 popup.id = 'day-popup-' + calId; 834 popup.className = 'day-popup'; 835 document.body.appendChild(popup); 836 } 837 838 // Get theme styles and important namespaces 839 const container = document.getElementById(calId); 840 const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {}; 841 const theme = container ? container.dataset.theme : 'matrix'; 842 843 // Get important namespaces 844 let importantNamespaces = ['important']; 845 if (container && container.dataset.importantNamespaces) { 846 try { 847 importantNamespaces = JSON.parse(container.dataset.importantNamespaces); 848 } catch (e) { 849 importantNamespaces = ['important']; 850 } 851 } 852 853 let html = '<div class="day-popup-overlay" onclick="closeDayPopup(\'' + calId + '\')"></div>'; 854 html += '<div class="day-popup-content">'; 855 html += '<div class="day-popup-header">'; 856 html += '<h4>' + displayDate + '</h4>'; 857 html += '<button class="popup-close" onclick="closeDayPopup(\'' + calId + '\')">×</button>'; 858 html += '</div>'; 859 860 html += '<div class="day-popup-body">'; 861 862 if (dayEventsWithConflicts.length === 0) { 863 html += '<p class="no-events-msg">No events on this day</p>'; 864 } else { 865 html += '<div class="popup-events-list">'; 866 dayEventsWithConflicts.forEach(event => { 867 const color = event.color || '#3498db'; 868 869 // Use individual event namespace if available (for multi-namespace support) 870 const eventNamespace = event._namespace !== undefined ? event._namespace : namespace; 871 872 // Check if this is an important namespace event 873 let isImportant = false; 874 if (eventNamespace) { 875 for (const impNs of importantNamespaces) { 876 if (eventNamespace === impNs || eventNamespace.startsWith(impNs + ':')) { 877 isImportant = true; 878 break; 879 } 880 } 881 } 882 883 // Check if this is a continuation (event started before this date) 884 const originalStartDate = event.originalStartDate || event._dateKey || date; 885 const isContinuation = originalStartDate < date; 886 887 // Convert to 12-hour format and handle time ranges 888 let displayTime = ''; 889 if (event.time) { 890 displayTime = formatTimeRange(event.time, event.endTime); 891 } 892 893 // Multi-day indicator 894 let multiDay = ''; 895 if (event.endDate && event.endDate !== date) { 896 const endObj = new Date(event.endDate + 'T00:00:00'); 897 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 898 month: 'short', 899 day: 'numeric' 900 }); 901 } 902 903 // Continuation message 904 if (isContinuation) { 905 const startObj = new Date(originalStartDate + 'T00:00:00'); 906 const startDisplay = startObj.toLocaleDateString('en-US', { 907 weekday: 'short', 908 month: 'short', 909 day: 'numeric' 910 }); 911 html += '<div class="popup-continuation-notice">↪ Continues from ' + startDisplay + '</div>'; 912 } 913 914 const importantClass = isImportant ? ' popup-event-important' : ''; 915 html += '<div class="popup-event-item' + importantClass + '" tabindex="0" role="listitem" aria-label="' + escapeHtml(event.title) + (displayTime ? ', ' + displayTime : '') + '">'; 916 html += '<div class="event-color-bar" style="background: ' + color + ';"></div>'; 917 html += '<div class="popup-event-content">'; 918 919 // Single line with title, time, date range, namespace, and actions 920 html += '<div class="popup-event-main-row">'; 921 html += '<div class="popup-event-info-inline">'; 922 923 // Add star for important events 924 if (isImportant) { 925 html += '<span class="popup-event-star">⭐</span>'; 926 } 927 928 html += '<span class="popup-event-title">' + escapeHtml(event.title) + '</span>'; 929 if (displayTime) { 930 html += '<span class="popup-event-time"> ' + displayTime + '</span>'; 931 } 932 if (multiDay) { 933 html += '<span class="popup-event-multiday">' + multiDay + '</span>'; 934 } 935 if (eventNamespace) { 936 html += '<span class="popup-event-namespace">' + escapeHtml(eventNamespace) + '</span>'; 937 } 938 939 // Add conflict warning badge if event has conflicts 940 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 941 // Build conflict list for tooltip 942 let conflictList = []; 943 event.conflictsWith.forEach(conflict => { 944 let conflictText = conflict.title; 945 if (conflict.time) { 946 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 947 } 948 conflictList.push(conflictText); 949 }); 950 951 html += '<span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 952 } 953 954 html += '</div>'; 955 html += '<div class="popup-event-actions">'; 956 html += '<button type="button" class="event-edit-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">✏️</button>'; 957 html += '<button type="button" class="event-delete-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">️</button>'; 958 html += '</div>'; 959 html += '</div>'; 960 961 // Description on separate line if present 962 if (event.description) { 963 html += '<div class="popup-event-desc">' + renderDescription(event.description) + '</div>'; 964 } 965 966 html += '</div></div>'; 967 }); 968 html += '</div>'; 969 } 970 971 html += '</div>'; 972 973 html += '<div class="day-popup-footer">'; 974 html += '<button class="btn-add-event" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\'); closeDayPopup(\'' + calId + '\')">+ Add Event</button>'; 975 html += '</div>'; 976 977 html += '</div>'; 978 979 popup.innerHTML = html; 980 popup.style.display = 'flex'; 981 982 // Propagate CSS vars from calendar container to popup (popup is outside container in DOM) 983 if (container) { 984 propagateThemeVars(calId, popup.querySelector('.day-popup-content')); 985 } 986 987 // Make popup draggable by header 988 const popupContent = popup.querySelector('.day-popup-content'); 989 const popupHeader = popup.querySelector('.day-popup-header'); 990 991 if (popupContent && popupHeader) { 992 // Reset position to center 993 popupContent.style.position = 'relative'; 994 popupContent.style.left = '0'; 995 popupContent.style.top = '0'; 996 997 // Store drag state on the element itself 998 popupHeader._isDragging = false; 999 1000 popupHeader.onmousedown = function(e) { 1001 // Ignore if clicking the close button 1002 if (e.target.classList.contains('popup-close')) return; 1003 1004 popupHeader._isDragging = true; 1005 popupHeader._dragStartX = e.clientX; 1006 popupHeader._dragStartY = e.clientY; 1007 1008 const rect = popupContent.getBoundingClientRect(); 1009 const parentRect = popup.getBoundingClientRect(); 1010 popupHeader._initialLeft = rect.left - parentRect.left - (parentRect.width / 2 - rect.width / 2); 1011 popupHeader._initialTop = rect.top - parentRect.top - (parentRect.height / 2 - rect.height / 2); 1012 1013 popupContent.style.transition = 'none'; 1014 e.preventDefault(); 1015 }; 1016 1017 popup.onmousemove = function(e) { 1018 if (!popupHeader._isDragging) return; 1019 1020 const deltaX = e.clientX - popupHeader._dragStartX; 1021 const deltaY = e.clientY - popupHeader._dragStartY; 1022 1023 popupContent.style.left = (popupHeader._initialLeft + deltaX) + 'px'; 1024 popupContent.style.top = (popupHeader._initialTop + deltaY) + 'px'; 1025 }; 1026 1027 popup.onmouseup = function() { 1028 if (popupHeader._isDragging) { 1029 popupHeader._isDragging = false; 1030 popupContent.style.transition = ''; 1031 } 1032 }; 1033 1034 popup.onmouseleave = function() { 1035 if (popupHeader._isDragging) { 1036 popupHeader._isDragging = false; 1037 popupContent.style.transition = ''; 1038 } 1039 }; 1040 } 1041}; 1042 1043// Close day popup 1044window.closeDayPopup = function(calId) { 1045 const popup = document.getElementById('day-popup-' + calId); 1046 if (popup) { 1047 popup.style.display = 'none'; 1048 } 1049}; 1050 1051// Show events for a specific day (for event list panel) 1052window.showDayEvents = function(calId, date, namespace) { 1053 const params = new URLSearchParams({ 1054 call: 'plugin_calendar', 1055 action: 'load_month', 1056 year: date.split('-')[0], 1057 month: parseInt(date.split('-')[1]), 1058 namespace: namespace, 1059 _: new Date().getTime() // Cache buster 1060 }); 1061 1062 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1063 method: 'POST', 1064 headers: { 1065 'Content-Type': 'application/x-www-form-urlencoded', 1066 'Cache-Control': 'no-cache, no-store, must-revalidate', 1067 'Pragma': 'no-cache' 1068 }, 1069 body: params.toString() 1070 }) 1071 .then(r => r.json()) 1072 .then(data => { 1073 if (data.success) { 1074 const eventList = document.getElementById('eventlist-' + calId); 1075 const events = data.events; 1076 const title = document.getElementById('eventlist-title-' + calId); 1077 1078 const dateObj = new Date(date + 'T00:00:00'); 1079 const displayDate = dateObj.toLocaleDateString('en-US', { 1080 weekday: 'short', 1081 month: 'short', 1082 day: 'numeric' 1083 }); 1084 1085 title.textContent = 'Events - ' + displayDate; 1086 1087 // Filter events for this day 1088 const dayEvents = events[date] || []; 1089 1090 if (dayEvents.length === 0) { 1091 eventList.innerHTML = '<p class="no-events-msg">No events on this day<br><button class="add-event-compact" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\')">+ Add Event</button></p>'; 1092 } else { 1093 let html = ''; 1094 dayEvents.forEach(event => { 1095 html += renderEventItem(event, date, calId, namespace); 1096 }); 1097 eventList.innerHTML = html; 1098 } 1099 } 1100 }) 1101 .catch(err => console.error('Error:', err)); 1102}; 1103 1104// Render a single event item 1105window.renderEventItem = function(event, date, calId, namespace) { 1106 // Get theme data from container 1107 const container = document.getElementById(calId); 1108 let themeStyles = {}; 1109 let importantNamespaces = ['important']; // default 1110 if (container && container.dataset.themeStyles) { 1111 try { 1112 themeStyles = JSON.parse(container.dataset.themeStyles); 1113 } catch (e) { 1114 console.error('Failed to parse theme styles:', e); 1115 } 1116 } 1117 // Get important namespaces from container data attribute 1118 if (container && container.dataset.importantNamespaces) { 1119 try { 1120 importantNamespaces = JSON.parse(container.dataset.importantNamespaces); 1121 } catch (e) { 1122 importantNamespaces = ['important']; 1123 } 1124 } 1125 1126 // Check if this event is in the past or today (with 15-minute grace period) 1127 // For multi-day events, use the end date instead of start date 1128 const today = new Date(); 1129 today.setHours(0, 0, 0, 0); 1130 const todayStr = formatLocalDate(today); 1131 const effectiveEndDate = event.endDate || date; 1132 const eventDate = new Date(date + 'T00:00:00'); 1133 1134 // Helper to determine if event is past with grace period 1135 let isPast; 1136 if (effectiveEndDate < todayStr) { 1137 isPast = true; // End date is past 1138 } else if (effectiveEndDate > todayStr) { 1139 isPast = false; // End date is future, event still ongoing 1140 } else { 1141 // Event ends today - check time with grace period 1142 if (event.time && event.time.trim() !== '') { 1143 try { 1144 const now = new Date(); 1145 const eventDateTime = new Date(effectiveEndDate + 'T' + event.time); 1146 const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000); 1147 isPast = now > gracePeriodEnd; 1148 } catch (e) { 1149 isPast = false; 1150 } 1151 } else { 1152 isPast = false; // No time, treat as future 1153 } 1154 } 1155 1156 const isToday = eventDate.getTime() === today.getTime(); 1157 1158 // Check if this is an important namespace event 1159 let eventNamespace = event.namespace || ''; 1160 if (!eventNamespace && event._namespace !== undefined) { 1161 eventNamespace = event._namespace; 1162 } 1163 let isImportantNs = false; 1164 if (eventNamespace) { 1165 for (const impNs of importantNamespaces) { 1166 if (eventNamespace === impNs || eventNamespace.startsWith(impNs + ':')) { 1167 isImportantNs = true; 1168 break; 1169 } 1170 } 1171 } 1172 1173 // Format date display with day of week 1174 const displayDateKey = event.originalStartDate || date; 1175 const dateObj = new Date(displayDateKey + 'T00:00:00'); 1176 const displayDate = dateObj.toLocaleDateString('en-US', { 1177 weekday: 'short', 1178 month: 'short', 1179 day: 'numeric' 1180 }); 1181 1182 // Convert to 12-hour format and handle time ranges 1183 let displayTime = ''; 1184 if (event.time) { 1185 displayTime = formatTimeRange(event.time, event.endTime); 1186 } 1187 1188 // Multi-day indicator 1189 let multiDay = ''; 1190 if (event.endDate && event.endDate !== displayDateKey) { 1191 const endObj = new Date(event.endDate + 'T00:00:00'); 1192 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 1193 weekday: 'short', 1194 month: 'short', 1195 day: 'numeric' 1196 }); 1197 } 1198 1199 const completedClass = event.completed ? ' event-completed' : ''; 1200 const isTask = event.isTask || false; 1201 const completed = event.completed || false; 1202 const isPastDue = isPast && isTask && !completed; 1203 const pastClass = (isPast && !isPastDue) ? ' event-past' : ''; 1204 const pastDueClass = isPastDue ? ' event-pastdue' : ''; 1205 const importantClass = isImportantNs ? ' event-important' : ''; 1206 const color = event.color || '#3498db'; 1207 1208 // Only inline style needed: border-left-color for event color indicator 1209 let html = '<div class="event-compact-item' + completedClass + pastClass + pastDueClass + importantClass + '" data-event-id="' + event.id + '" data-date="' + date + '" style="border-left-color: ' + color + ' !important;" onclick="' + (isPast && !isPastDue ? 'togglePastEventExpand(this)' : '') + '">'; 1210 1211 html += '<div class="event-info">'; 1212 html += '<div class="event-title-row">'; 1213 // Add star for important namespace events 1214 if (isImportantNs) { 1215 html += '<span class="event-important-star" title="Important">⭐</span> '; 1216 } 1217 html += '<span class="event-title-compact">' + escapeHtml(event.title) + '</span>'; 1218 html += '</div>'; 1219 1220 // Show meta and description for non-past events AND past due tasks 1221 if (!isPast || isPastDue) { 1222 html += '<div class="event-meta-compact">'; 1223 html += '<span class="event-date-time">' + displayDate + multiDay; 1224 if (displayTime) { 1225 html += ' • ' + displayTime; 1226 } 1227 // Add PAST DUE or TODAY badge 1228 if (isPastDue) { 1229 html += ' <span class="event-pastdue-badge" style="background:var(--pastdue-color, #e74c3c) !important; color:white !important; -webkit-text-fill-color:white !important;">PAST DUE</span>'; 1230 } else if (isToday) { 1231 html += ' <span class="event-today-badge" style="background:var(--border-main, #9b59b6) !important; color:var(--background-site, white) !important; -webkit-text-fill-color:var(--background-site, white) !important;">TODAY</span>'; 1232 } 1233 // Add namespace badge 1234 if (eventNamespace) { 1235 html += ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' + calId + '\', \'' + escapeHtml(eventNamespace) + '\')" style="background:var(--text-bright, #008800) !important; color:var(--background-site, white) !important; -webkit-text-fill-color:var(--background-site, white) !important;" title="Click to filter by this namespace">' + escapeHtml(eventNamespace) + '</span>'; 1236 } 1237 // Add conflict warning if event has time conflicts 1238 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 1239 let conflictList = []; 1240 event.conflictsWith.forEach(conflict => { 1241 let conflictText = conflict.title; 1242 if (conflict.time) { 1243 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 1244 } 1245 conflictList.push(conflictText); 1246 }); 1247 1248 html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 1249 } 1250 html += '</span>'; 1251 html += '</div>'; 1252 1253 if (event.description) { 1254 html += '<div class="event-desc-compact">' + renderDescription(event.description) + '</div>'; 1255 } 1256 } else { 1257 // For past events (not past due), store data in hidden divs for expand/collapse 1258 html += '<div class="event-meta-compact" style="display: none;">'; 1259 html += '<span class="event-date-time">' + displayDate + multiDay; 1260 if (displayTime) { 1261 html += ' • ' + displayTime; 1262 } 1263 // Add namespace badge for past events too 1264 let eventNamespace = event.namespace || ''; 1265 if (!eventNamespace && event._namespace !== undefined) { 1266 eventNamespace = event._namespace; 1267 } 1268 if (eventNamespace) { 1269 html += ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' + calId + '\', \'' + escapeHtml(eventNamespace) + '\')" style="background:var(--text-bright, #008800) !important; color:var(--background-site, white) !important; -webkit-text-fill-color:var(--background-site, white) !important;" title="Click to filter by this namespace">' + escapeHtml(eventNamespace) + '</span>'; 1270 } 1271 // Add conflict warning for past events too 1272 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 1273 let conflictList = []; 1274 event.conflictsWith.forEach(conflict => { 1275 let conflictText = conflict.title; 1276 if (conflict.time) { 1277 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 1278 } 1279 conflictList.push(conflictText); 1280 }); 1281 1282 html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 1283 } 1284 html += '</span>'; 1285 html += '</div>'; 1286 1287 if (event.description) { 1288 html += '<div class="event-desc-compact" style="display: none;">' + renderDescription(event.description) + '</div>'; 1289 } 1290 } 1291 1292 html += '</div>'; // event-info 1293 1294 // Use stored namespace from event, fallback to _namespace, then passed namespace 1295 let buttonNamespace = event.namespace || ''; 1296 if (!buttonNamespace && event._namespace !== undefined) { 1297 buttonNamespace = event._namespace; 1298 } 1299 if (!buttonNamespace) { 1300 buttonNamespace = namespace; 1301 } 1302 1303 html += '<div class="event-actions-compact">'; 1304 html += '<button class="event-action-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">️</button>'; 1305 html += '<button class="event-action-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">✏️</button>'; 1306 html += '</div>'; 1307 1308 // Checkbox for tasks - ON THE FAR RIGHT 1309 if (isTask) { 1310 const checked = completed ? 'checked' : ''; 1311 html += '<input type="checkbox" class="task-checkbox" ' + checked + ' onclick="toggleTaskComplete(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\', this.checked)">'; 1312 } 1313 1314 html += '</div>'; 1315 1316 return html; 1317}; 1318 1319// Render description with rich content support 1320window.renderDescription = function(description) { 1321 if (!description) return ''; 1322 1323 // First, convert DokuWiki/Markdown syntax to placeholder tokens (before escaping) 1324 // Use a format that won't be affected by HTML escaping: \x00TOKEN_N\x00 1325 1326 let rendered = description; 1327 const tokens = []; 1328 let tokenIndex = 0; 1329 1330 // Convert DokuWiki image syntax {{image.jpg}} to tokens 1331 rendered = rendered.replace(/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/g, function(match, imagePath, alt) { 1332 imagePath = imagePath.trim(); 1333 alt = alt ? alt.trim() : ''; 1334 1335 let imageHtml; 1336 // Handle external URLs 1337 if (imagePath.match(/^https?:\/\//)) { 1338 imageHtml = '<img src="' + imagePath + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 1339 } else { 1340 // Handle internal DokuWiki images 1341 const imageUrl = DOKU_BASE + 'lib/exe/fetch.php?media=' + encodeURIComponent(imagePath); 1342 imageHtml = '<img src="' + imageUrl + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 1343 } 1344 1345 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1346 tokens[tokenIndex] = imageHtml; 1347 tokenIndex++; 1348 return token; 1349 }); 1350 1351 // Convert DokuWiki link syntax [[link|text]] to tokens 1352 rendered = rendered.replace(/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/g, function(match, link, text) { 1353 link = link.trim(); 1354 text = text ? text.trim() : link; 1355 1356 let linkHtml; 1357 // Handle external URLs 1358 if (link.match(/^https?:\/\//)) { 1359 linkHtml = '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 1360 } else { 1361 // Handle internal DokuWiki links with section anchors 1362 const hashIndex = link.indexOf('#'); 1363 let pagePart = link; 1364 let sectionPart = ''; 1365 1366 if (hashIndex !== -1) { 1367 pagePart = link.substring(0, hashIndex); 1368 sectionPart = link.substring(hashIndex); // Includes the # 1369 } 1370 1371 const wikiUrl = DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pagePart) + sectionPart; 1372 linkHtml = '<a href="' + wikiUrl + '">' + escapeHtml(text) + '</a>'; 1373 } 1374 1375 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1376 tokens[tokenIndex] = linkHtml; 1377 tokenIndex++; 1378 return token; 1379 }); 1380 1381 // Convert markdown-style links [text](url) to tokens 1382 rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) { 1383 text = text.trim(); 1384 url = url.trim(); 1385 1386 let linkHtml; 1387 if (url.match(/^https?:\/\//)) { 1388 linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 1389 } else { 1390 linkHtml = '<a href="' + escapeHtml(url) + '">' + escapeHtml(text) + '</a>'; 1391 } 1392 1393 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1394 tokens[tokenIndex] = linkHtml; 1395 tokenIndex++; 1396 return token; 1397 }); 1398 1399 // Convert plain URLs to tokens 1400 rendered = rendered.replace(/(https?:\/\/[^\s<]+)/g, function(match, url) { 1401 const linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(url) + '</a>'; 1402 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1403 tokens[tokenIndex] = linkHtml; 1404 tokenIndex++; 1405 return token; 1406 }); 1407 1408 // NOW escape the remaining text (tokens are protected with null bytes) 1409 rendered = escapeHtml(rendered); 1410 1411 // Convert newlines to <br> 1412 rendered = rendered.replace(/\n/g, '<br>'); 1413 1414 // DokuWiki text formatting (on escaped text) 1415 // Bold: **text** or __text__ 1416 rendered = rendered.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 1417 rendered = rendered.replace(/__(.+?)__/g, '<strong>$1</strong>'); 1418 1419 // Italic: //text// 1420 rendered = rendered.replace(/\/\/(.+?)\/\//g, '<em>$1</em>'); 1421 1422 // Strikethrough: <del>text</del> 1423 rendered = rendered.replace(/<del>(.+?)<\/del>/g, '<del>$1</del>'); 1424 1425 // Monospace: ''text'' 1426 rendered = rendered.replace(/''(.+?)''/g, '<code>$1</code>'); 1427 1428 // Subscript: <sub>text</sub> 1429 rendered = rendered.replace(/<sub>(.+?)<\/sub>/g, '<sub>$1</sub>'); 1430 1431 // Superscript: <sup>text</sup> 1432 rendered = rendered.replace(/<sup>(.+?)<\/sup>/g, '<sup>$1</sup>'); 1433 1434 // Restore tokens (replace with actual HTML) 1435 for (let i = 0; i < tokens.length; i++) { 1436 const tokenPattern = new RegExp('\x00TOKEN' + i + '\x00', 'g'); 1437 rendered = rendered.replace(tokenPattern, tokens[i]); 1438 } 1439 1440 return rendered; 1441} 1442 1443// Open add event dialog 1444window.openAddEvent = function(calId, namespace, date) { 1445 const dialog = document.getElementById('dialog-' + calId); 1446 const form = document.getElementById('eventform-' + calId); 1447 const title = document.getElementById('dialog-title-' + calId); 1448 const dateField = document.getElementById('event-date-' + calId); 1449 1450 if (!dateField) { 1451 console.error('Date field not found! ID: event-date-' + calId); 1452 return; 1453 } 1454 1455 // Check if there's a filtered namespace active (only for regular calendars) 1456 const calendar = document.getElementById(calId); 1457 const filteredNamespace = calendar ? calendar.dataset.filteredNamespace : null; 1458 1459 // Use filtered namespace if available, otherwise use the passed namespace 1460 const effectiveNamespace = filteredNamespace || namespace; 1461 1462 1463 // Reset form 1464 form.reset(); 1465 document.getElementById('event-id-' + calId).value = ''; 1466 1467 // Store the effective namespace in a hidden field or data attribute 1468 form.dataset.effectiveNamespace = effectiveNamespace; 1469 1470 // Set namespace dropdown to effective namespace 1471 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1472 if (namespaceSelect) { 1473 if (effectiveNamespace && effectiveNamespace !== '*' && effectiveNamespace.indexOf(';') === -1) { 1474 // Set to specific namespace if not wildcard or multi-namespace 1475 namespaceSelect.value = effectiveNamespace; 1476 } else { 1477 // Default to empty (default namespace) for wildcard/multi views 1478 namespaceSelect.value = ''; 1479 } 1480 } 1481 1482 // Clear event namespace from previous edits 1483 delete form.dataset.eventNamespace; 1484 1485 // Set date - use local date, not UTC 1486 let defaultDate = date; 1487 if (!defaultDate) { 1488 // Get the currently displayed month from the calendar container 1489 const container = document.getElementById(calId); 1490 const displayedYear = parseInt(container.getAttribute('data-year')); 1491 const displayedMonth = parseInt(container.getAttribute('data-month')); 1492 1493 1494 if (displayedYear && displayedMonth) { 1495 // Use first day of the displayed month 1496 const year = displayedYear; 1497 const month = String(displayedMonth).padStart(2, '0'); 1498 defaultDate = `${year}-${month}-01`; 1499 } else { 1500 // Fallback to today if attributes not found 1501 const today = new Date(); 1502 const year = today.getFullYear(); 1503 const month = String(today.getMonth() + 1).padStart(2, '0'); 1504 const day = String(today.getDate()).padStart(2, '0'); 1505 defaultDate = `${year}-${month}-${day}`; 1506 } 1507 } 1508 dateField.value = defaultDate; 1509 dateField.removeAttribute('data-original-date'); 1510 1511 // Also set the end date field to the same default (user can change it) 1512 const endDateField = document.getElementById('event-end-date-' + calId); 1513 if (endDateField) { 1514 endDateField.value = ''; // Empty by default (single-day event) 1515 } 1516 1517 // Set default color 1518 document.getElementById('event-color-' + calId).value = '#3498db'; 1519 1520 // Reset time pickers to default state 1521 setTimePicker(calId, false, ''); // Start time = All day 1522 setTimePicker(calId, true, ''); // End time = Same as start 1523 1524 // Set date pickers 1525 setDatePicker(calId, false, defaultDate); // Start date 1526 setDatePicker(calId, true, ''); // End date = Optional 1527 1528 // Initialize namespace search 1529 initNamespaceSearch(calId); 1530 1531 // Set title 1532 title.textContent = 'Add Event'; 1533 1534 // Show dialog 1535 dialog.style.display = 'flex'; 1536 1537 // Propagate CSS vars to dialog (position:fixed can break inheritance in some templates) 1538 propagateThemeVars(calId, dialog); 1539 1540 // Initialize custom pickers 1541 initCustomTimePickers(calId); 1542 initCustomDatePickers(calId); 1543 1544 // Make dialog draggable 1545 setTimeout(() => makeDialogDraggable(calId), 50); 1546 1547 // Focus title field 1548 setTimeout(() => { 1549 const titleField = document.getElementById('event-title-' + calId); 1550 if (titleField) titleField.focus(); 1551 }, 100); 1552}; 1553 1554// Edit event 1555window.editEvent = function(calId, eventId, date, namespace) { 1556 const params = new URLSearchParams({ 1557 call: 'plugin_calendar', 1558 action: 'get_event', 1559 namespace: namespace, 1560 date: date, 1561 eventId: eventId 1562 }); 1563 1564 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1565 method: 'POST', 1566 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1567 body: params.toString() 1568 }) 1569 .then(r => r.json()) 1570 .then(data => { 1571 if (data.success && data.event) { 1572 const event = data.event; 1573 const dialog = document.getElementById('dialog-' + calId); 1574 const title = document.getElementById('dialog-title-' + calId); 1575 const dateField = document.getElementById('event-date-' + calId); 1576 const form = document.getElementById('eventform-' + calId); 1577 1578 if (!dateField) { 1579 console.error('Date field not found when editing!'); 1580 return; 1581 } 1582 1583 // Store the event's actual namespace for saving (important for namespace=* views) 1584 if (event.namespace !== undefined) { 1585 form.dataset.eventNamespace = event.namespace; 1586 } 1587 1588 // Populate form 1589 document.getElementById('event-id-' + calId).value = event.id; 1590 dateField.value = date; 1591 dateField.setAttribute('data-original-date', date); 1592 1593 const endDateField = document.getElementById('event-end-date-' + calId); 1594 endDateField.value = event.endDate || ''; 1595 1596 document.getElementById('event-title-' + calId).value = event.title; 1597 document.getElementById('event-color-' + calId).value = event.color || '#3498db'; 1598 document.getElementById('event-desc-' + calId).value = event.description || ''; 1599 document.getElementById('event-is-task-' + calId).checked = event.isTask || false; 1600 1601 // Set time picker values using custom picker API 1602 setTimePicker(calId, false, event.time || ''); 1603 setTimePicker(calId, true, event.endTime || ''); 1604 1605 // Set date picker values 1606 setDatePicker(calId, false, date); 1607 setDatePicker(calId, true, event.endDate || ''); 1608 1609 // Initialize namespace search 1610 initNamespaceSearch(calId); 1611 1612 // Set namespace fields if available 1613 const namespaceHidden = document.getElementById('event-namespace-' + calId); 1614 const namespaceSearch = document.getElementById('event-namespace-search-' + calId); 1615 if (namespaceHidden && event.namespace !== undefined) { 1616 // Set the hidden input (this is what gets submitted) 1617 namespaceHidden.value = event.namespace || ''; 1618 // Set the search input to display the namespace 1619 if (namespaceSearch) { 1620 namespaceSearch.value = event.namespace || '(default)'; 1621 } 1622 } else { 1623 // No namespace on event, set to default 1624 if (namespaceHidden) { 1625 namespaceHidden.value = ''; 1626 } 1627 if (namespaceSearch) { 1628 namespaceSearch.value = '(default)'; 1629 } 1630 } 1631 1632 title.textContent = 'Edit Event'; 1633 dialog.style.display = 'flex'; 1634 1635 // Propagate CSS vars to dialog 1636 propagateThemeVars(calId, dialog); 1637 1638 // Initialize custom pickers 1639 initCustomTimePickers(calId); 1640 initCustomDatePickers(calId); 1641 1642 // Make dialog draggable 1643 setTimeout(() => makeDialogDraggable(calId), 50); 1644 } 1645 }) 1646 .catch(err => console.error('Error editing event:', err)); 1647}; 1648 1649// Delete event 1650window.deleteEvent = function(calId, eventId, date, namespace) { 1651 if (!confirm('Delete this event?')) return; 1652 1653 const params = new URLSearchParams({ 1654 call: 'plugin_calendar', 1655 action: 'delete_event', 1656 namespace: namespace, 1657 date: date, 1658 eventId: eventId, 1659 sectok: getSecurityToken() 1660 }); 1661 1662 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1663 method: 'POST', 1664 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1665 body: params.toString() 1666 }) 1667 .then(r => r.json()) 1668 .then(data => { 1669 if (data.success) { 1670 // Announce to screen readers 1671 announceToScreenReader('Event deleted'); 1672 1673 // Extract year and month from date 1674 const [year, month] = date.split('-').map(Number); 1675 1676 // Get the calendar's ORIGINAL namespace setting (not the deleted event's namespace) 1677 // This preserves wildcard/multi-namespace views 1678 const container = document.getElementById(calId); 1679 const calendarNamespace = container ? (container.dataset.namespace || '') : namespace; 1680 1681 // Reload calendar data via AJAX with the calendar's original namespace 1682 reloadCalendarData(calId, year, month, calendarNamespace); 1683 } 1684 }) 1685 .catch(err => console.error('Error:', err)); 1686}; 1687 1688// Save event (add or edit) 1689window.saveEventCompact = function(calId, namespace) { 1690 const form = document.getElementById('eventform-' + calId); 1691 1692 // Get namespace from dropdown - this is what the user selected 1693 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1694 const selectedNamespace = namespaceSelect ? namespaceSelect.value : ''; 1695 1696 // ALWAYS use what the user selected in the dropdown 1697 // This allows changing namespace when editing 1698 const finalNamespace = selectedNamespace; 1699 1700 const eventId = document.getElementById('event-id-' + calId).value; 1701 1702 // eventNamespace is the ORIGINAL namespace (only used for finding/deleting old event) 1703 const originalNamespace = form.dataset.eventNamespace; 1704 1705 1706 const dateInput = document.getElementById('event-date-' + calId); 1707 const date = dateInput.value; 1708 const oldDate = dateInput.getAttribute('data-original-date') || date; 1709 const endDate = document.getElementById('event-end-date-' + calId).value; 1710 const title = document.getElementById('event-title-' + calId).value; 1711 const time = document.getElementById('event-time-' + calId).value; 1712 const endTime = document.getElementById('event-end-time-' + calId).value; 1713 const colorSelect = document.getElementById('event-color-' + calId); 1714 let color = colorSelect.value; 1715 1716 // Handle custom color 1717 if (color === 'custom') { 1718 color = colorSelect.dataset.customColor || document.getElementById('event-color-custom-' + calId).value; 1719 } 1720 1721 const description = document.getElementById('event-desc-' + calId).value; 1722 const isTask = document.getElementById('event-is-task-' + calId).checked; 1723 const completed = false; // New tasks are not completed 1724 const isRecurring = document.getElementById('event-recurring-' + calId).checked; 1725 const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value; 1726 const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value; 1727 1728 // New recurrence options 1729 const recurrenceIntervalInput = document.getElementById('event-recurrence-interval-' + calId); 1730 const recurrenceInterval = recurrenceIntervalInput ? parseInt(recurrenceIntervalInput.value) || 1 : 1; 1731 1732 // Weekly: collect selected days 1733 let weekDays = []; 1734 const weeklyOptions = document.getElementById('weekly-options-' + calId); 1735 if (weeklyOptions && recurrenceType === 'weekly') { 1736 const checkboxes = weeklyOptions.querySelectorAll('input[name="weekDays[]"]:checked'); 1737 weekDays = Array.from(checkboxes).map(cb => cb.value); 1738 } 1739 1740 // Monthly: collect day-of-month or ordinal weekday 1741 let monthDay = ''; 1742 let monthlyType = 'dayOfMonth'; 1743 let ordinalWeek = ''; 1744 let ordinalDay = ''; 1745 const monthlyOptions = document.getElementById('monthly-options-' + calId); 1746 if (monthlyOptions && recurrenceType === 'monthly') { 1747 const monthlyTypeRadio = monthlyOptions.querySelector('input[name="monthlyType"]:checked'); 1748 monthlyType = monthlyTypeRadio ? monthlyTypeRadio.value : 'dayOfMonth'; 1749 1750 if (monthlyType === 'dayOfMonth') { 1751 const monthDayInput = document.getElementById('event-month-day-' + calId); 1752 monthDay = monthDayInput ? monthDayInput.value : ''; 1753 } else { 1754 const ordinalSelect = document.getElementById('event-ordinal-' + calId); 1755 const ordinalDaySelect = document.getElementById('event-ordinal-day-' + calId); 1756 ordinalWeek = ordinalSelect ? ordinalSelect.value : '1'; 1757 ordinalDay = ordinalDaySelect ? ordinalDaySelect.value : '0'; 1758 } 1759 } 1760 1761 if (!title) { 1762 alert('Please enter a title'); 1763 return; 1764 } 1765 1766 if (!date) { 1767 alert('Please select a date'); 1768 return; 1769 } 1770 1771 const params = new URLSearchParams({ 1772 call: 'plugin_calendar', 1773 action: 'save_event', 1774 namespace: finalNamespace, 1775 eventId: eventId, 1776 date: date, 1777 oldDate: oldDate, 1778 endDate: endDate, 1779 title: title, 1780 time: time, 1781 endTime: endTime, 1782 color: color, 1783 description: description, 1784 isTask: isTask ? '1' : '0', 1785 completed: completed ? '1' : '0', 1786 isRecurring: isRecurring ? '1' : '0', 1787 recurrenceType: recurrenceType, 1788 recurrenceInterval: recurrenceInterval, 1789 recurrenceEnd: recurrenceEnd, 1790 weekDays: weekDays.join(','), 1791 monthlyType: monthlyType, 1792 monthDay: monthDay, 1793 ordinalWeek: ordinalWeek, 1794 ordinalDay: ordinalDay, 1795 sectok: getSecurityToken() 1796 }); 1797 1798 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1799 method: 'POST', 1800 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1801 body: params.toString() 1802 }) 1803 .then(r => r.json()) 1804 .then(data => { 1805 if (data.success) { 1806 // Announce to screen readers 1807 announceToScreenReader(eventId ? 'Event updated' : 'Event created'); 1808 1809 closeEventDialog(calId); 1810 1811 // For recurring events, do a full page reload to show all occurrences 1812 if (isRecurring) { 1813 location.reload(); 1814 return; 1815 } 1816 1817 // Extract year and month from the NEW date (in case date was changed) 1818 const [year, month] = date.split('-').map(Number); 1819 1820 // Get the calendar's ORIGINAL namespace setting from the container 1821 // This preserves wildcard/multi-namespace views after editing 1822 const container = document.getElementById(calId); 1823 const calendarNamespace = container ? (container.dataset.namespace || '') : namespace; 1824 1825 // Reload calendar data via AJAX to the month of the event 1826 reloadCalendarData(calId, year, month, calendarNamespace); 1827 } else { 1828 alert('Error: ' + (data.error || 'Unknown error')); 1829 } 1830 }) 1831 .catch(err => { 1832 console.error('Error:', err); 1833 alert('Error saving event'); 1834 }); 1835}; 1836 1837// Reload calendar data without page refresh 1838window.reloadCalendarData = function(calId, year, month, namespace) { 1839 // Read exclude list from container data attribute 1840 const container = document.getElementById(calId); 1841 const exclude = container ? (container.dataset.exclude || '') : ''; 1842 1843 const params = new URLSearchParams({ 1844 call: 'plugin_calendar', 1845 action: 'load_month', 1846 year: year, 1847 month: month, 1848 namespace: namespace, 1849 exclude: exclude, 1850 _: new Date().getTime() // Cache buster 1851 }); 1852 1853 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1854 method: 'POST', 1855 headers: { 1856 'Content-Type': 'application/x-www-form-urlencoded', 1857 'Cache-Control': 'no-cache, no-store, must-revalidate', 1858 'Pragma': 'no-cache' 1859 }, 1860 body: params.toString() 1861 }) 1862 .then(r => r.json()) 1863 .then(data => { 1864 if (data.success) { 1865 const container = document.getElementById(calId); 1866 1867 // Check if this is a full calendar or just event panel 1868 if (container.classList.contains('calendar-compact-container')) { 1869 rebuildCalendar(calId, data.year, data.month, data.events, namespace); 1870 } else if (container.classList.contains('event-panel-standalone')) { 1871 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1872 } 1873 } 1874 }) 1875 .catch(err => console.error('Error:', err)); 1876}; 1877 1878// Close event dialog 1879window.closeEventDialog = function(calId) { 1880 const dialog = document.getElementById('dialog-' + calId); 1881 dialog.style.display = 'none'; 1882}; 1883 1884// Escape HTML 1885window.escapeHtml = function(text) { 1886 const div = document.createElement('div'); 1887 div.textContent = text; 1888 return div.innerHTML; 1889}; 1890 1891// Highlight event when clicking on bar in calendar 1892window.highlightEvent = function(calId, eventId, date) { 1893 1894 // Find the event item in the event list 1895 const eventList = document.querySelector('#' + calId + ' .event-list-compact'); 1896 if (!eventList) { 1897 return; 1898 } 1899 1900 const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]'); 1901 if (!eventItem) { 1902 return; 1903 } 1904 1905 1906 // Get theme 1907 const container = document.getElementById(calId); 1908 const theme = container ? container.dataset.theme : 'matrix'; 1909 const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {}; 1910 1911 1912 // Theme-specific highlight colors 1913 let highlightBg, highlightShadow; 1914 if (theme === 'matrix') { 1915 highlightBg = '#1a3d1a'; // Darker green 1916 highlightShadow = '0 0 20px rgba(0, 204, 7, 0.8), 0 0 40px rgba(0, 204, 7, 0.4)'; 1917 } else if (theme === 'purple') { 1918 highlightBg = '#3d2b4d'; // Darker purple 1919 highlightShadow = '0 0 20px rgba(155, 89, 182, 0.8), 0 0 40px rgba(155, 89, 182, 0.4)'; 1920 } else if (theme === 'professional') { 1921 highlightBg = '#e3f2fd'; // Light blue 1922 highlightShadow = '0 0 20px rgba(74, 144, 226, 0.4)'; 1923 } else if (theme === 'pink') { 1924 highlightBg = '#3d2030'; // Darker pink 1925 highlightShadow = '0 0 20px rgba(255, 20, 147, 0.8), 0 0 40px rgba(255, 20, 147, 0.4)'; 1926 } else if (theme === 'wiki') { 1927 highlightBg = themeStyles.header_bg || '#e8e8e8'; // __background_alt__ 1928 highlightShadow = '0 0 10px rgba(0, 0, 0, 0.15)'; 1929 } 1930 1931 1932 // Store original styles 1933 const originalBg = eventItem.style.background; 1934 const originalShadow = eventItem.style.boxShadow; 1935 1936 // Remove previous highlights (restore their original styles) 1937 const previousHighlights = eventList.querySelectorAll('.event-highlighted'); 1938 previousHighlights.forEach(el => { 1939 el.classList.remove('event-highlighted'); 1940 }); 1941 1942 // Add highlight class and apply theme-aware glow 1943 eventItem.classList.add('event-highlighted'); 1944 1945 // Set CSS properties directly 1946 eventItem.style.setProperty('background', highlightBg, 'important'); 1947 eventItem.style.setProperty('box-shadow', highlightShadow, 'important'); 1948 eventItem.style.setProperty('transition', 'all 0.3s ease-in-out', 'important'); 1949 1950 1951 // Scroll to event 1952 eventItem.scrollIntoView({ 1953 behavior: 'smooth', 1954 block: 'nearest', 1955 inline: 'nearest' 1956 }); 1957 1958 // Remove highlight after 3 seconds and restore original styles 1959 setTimeout(() => { 1960 eventItem.classList.remove('event-highlighted'); 1961 eventItem.style.setProperty('background', originalBg); 1962 eventItem.style.setProperty('box-shadow', originalShadow); 1963 eventItem.style.setProperty('transition', ''); 1964 }, 3000); 1965}; 1966 1967// Toggle recurring event options 1968window.toggleRecurringOptions = function(calId) { 1969 const checkbox = document.getElementById('event-recurring-' + calId); 1970 const options = document.getElementById('recurring-options-' + calId); 1971 1972 if (checkbox && options) { 1973 options.style.display = checkbox.checked ? 'block' : 'none'; 1974 if (checkbox.checked) { 1975 // Initialize the sub-options based on current selection 1976 updateRecurrenceOptions(calId); 1977 } 1978 } 1979}; 1980 1981// Update visible recurrence options based on type (daily/weekly/monthly/yearly) 1982window.updateRecurrenceOptions = function(calId) { 1983 const typeSelect = document.getElementById('event-recurrence-type-' + calId); 1984 const weeklyOptions = document.getElementById('weekly-options-' + calId); 1985 const monthlyOptions = document.getElementById('monthly-options-' + calId); 1986 1987 if (!typeSelect) return; 1988 1989 const recurrenceType = typeSelect.value; 1990 1991 // Hide all conditional options first 1992 if (weeklyOptions) weeklyOptions.style.display = 'none'; 1993 if (monthlyOptions) monthlyOptions.style.display = 'none'; 1994 1995 // Show relevant options 1996 if (recurrenceType === 'weekly' && weeklyOptions) { 1997 weeklyOptions.style.display = 'block'; 1998 // Auto-select today's day of week if nothing selected 1999 const checkboxes = weeklyOptions.querySelectorAll('input[type="checkbox"]'); 2000 const anyChecked = Array.from(checkboxes).some(cb => cb.checked); 2001 if (!anyChecked) { 2002 const today = new Date().getDay(); 2003 const todayCheckbox = weeklyOptions.querySelector('input[value="' + today + '"]'); 2004 if (todayCheckbox) todayCheckbox.checked = true; 2005 } 2006 } else if (recurrenceType === 'monthly' && monthlyOptions) { 2007 monthlyOptions.style.display = 'block'; 2008 // Set default day to current day of month 2009 const monthDayInput = document.getElementById('event-month-day-' + calId); 2010 if (monthDayInput && !monthDayInput.dataset.userSet) { 2011 monthDayInput.value = new Date().getDate(); 2012 } 2013 } 2014}; 2015 2016// Toggle between day-of-month and ordinal weekday for monthly recurrence 2017window.updateMonthlyType = function(calId) { 2018 const dayOfMonthDiv = document.getElementById('monthly-day-' + calId); 2019 const ordinalDiv = document.getElementById('monthly-ordinal-' + calId); 2020 const monthlyOptions = document.getElementById('monthly-options-' + calId); 2021 2022 if (!monthlyOptions) return; 2023 2024 const selectedRadio = monthlyOptions.querySelector('input[name="monthlyType"]:checked'); 2025 if (!selectedRadio) return; 2026 2027 if (selectedRadio.value === 'dayOfMonth') { 2028 if (dayOfMonthDiv) dayOfMonthDiv.style.display = 'flex'; 2029 if (ordinalDiv) ordinalDiv.style.display = 'none'; 2030 } else { 2031 if (dayOfMonthDiv) dayOfMonthDiv.style.display = 'none'; 2032 if (ordinalDiv) ordinalDiv.style.display = 'block'; 2033 2034 // Set defaults based on current date 2035 const now = new Date(); 2036 const dayOfWeek = now.getDay(); 2037 const weekOfMonth = Math.ceil(now.getDate() / 7); 2038 2039 const ordinalSelect = document.getElementById('event-ordinal-' + calId); 2040 const ordinalDaySelect = document.getElementById('event-ordinal-day-' + calId); 2041 2042 if (ordinalSelect && !ordinalSelect.dataset.userSet) { 2043 ordinalSelect.value = weekOfMonth; 2044 } 2045 if (ordinalDaySelect && !ordinalDaySelect.dataset.userSet) { 2046 ordinalDaySelect.value = dayOfWeek; 2047 } 2048 } 2049}; 2050 2051// ============================================================ 2052// Document-level event delegation (guarded - only attach once) 2053// These use event delegation so they work for AJAX-rebuilt content. 2054// ============================================================ 2055if (!window._calendarDelegationInit) { 2056 window._calendarDelegationInit = true; 2057 2058 // Keyboard navigation for accessibility 2059 document.addEventListener('keydown', function(e) { 2060 // ESC closes dialogs, popups, tooltips, dropdowns 2061 if (e.key === 'Escape') { 2062 // Close dialogs 2063 document.querySelectorAll('.event-dialog-compact').forEach(function(d) { 2064 if (d.style.display === 'flex') d.style.display = 'none'; 2065 }); 2066 // Close day popups 2067 document.querySelectorAll('.day-popup').forEach(function(p) { 2068 p.style.display = 'none'; 2069 }); 2070 // Close custom pickers 2071 document.querySelectorAll('.time-dropdown.open, .date-dropdown.open').forEach(function(d) { 2072 d.classList.remove('open'); 2073 d.innerHTML = ''; 2074 }); 2075 document.querySelectorAll('.custom-time-picker.open, .custom-date-picker.open').forEach(function(b) { 2076 b.classList.remove('open'); 2077 }); 2078 hideConflictTooltip(); 2079 return; 2080 } 2081 2082 // Calendar grid navigation with arrow keys 2083 var focusedDay = document.activeElement; 2084 if (focusedDay && focusedDay.classList.contains('calendar-day')) { 2085 var calGrid = focusedDay.closest('.calendar-grid'); 2086 if (!calGrid) return; 2087 2088 var days = Array.from(calGrid.querySelectorAll('.calendar-day:not(.empty)')); 2089 var currentIndex = days.indexOf(focusedDay); 2090 if (currentIndex === -1) return; 2091 2092 var newIndex = currentIndex; 2093 2094 if (e.key === 'ArrowRight') { 2095 newIndex = Math.min(currentIndex + 1, days.length - 1); 2096 e.preventDefault(); 2097 } else if (e.key === 'ArrowLeft') { 2098 newIndex = Math.max(currentIndex - 1, 0); 2099 e.preventDefault(); 2100 } else if (e.key === 'ArrowDown') { 2101 newIndex = Math.min(currentIndex + 7, days.length - 1); 2102 e.preventDefault(); 2103 } else if (e.key === 'ArrowUp') { 2104 newIndex = Math.max(currentIndex - 7, 0); 2105 e.preventDefault(); 2106 } else if (e.key === 'Enter' || e.key === ' ') { 2107 // Activate the day (click it) 2108 focusedDay.click(); 2109 e.preventDefault(); 2110 return; 2111 } 2112 2113 if (newIndex !== currentIndex && days[newIndex]) { 2114 days[newIndex].focus(); 2115 } 2116 } 2117 2118 // Event item navigation with arrow keys 2119 var focusedEvent = document.activeElement; 2120 if (focusedEvent && focusedEvent.classList.contains('event-item')) { 2121 var eventList = focusedEvent.closest('.event-list-items, .day-popup-events'); 2122 if (!eventList) return; 2123 2124 var events = Array.from(eventList.querySelectorAll('.event-item')); 2125 var currentIdx = events.indexOf(focusedEvent); 2126 if (currentIdx === -1) return; 2127 2128 if (e.key === 'ArrowDown') { 2129 var nextIdx = Math.min(currentIdx + 1, events.length - 1); 2130 events[nextIdx].focus(); 2131 e.preventDefault(); 2132 } else if (e.key === 'ArrowUp') { 2133 var prevIdx = Math.max(currentIdx - 1, 0); 2134 events[prevIdx].focus(); 2135 e.preventDefault(); 2136 } else if (e.key === 'Enter') { 2137 // Find and click the edit button 2138 var editBtn = focusedEvent.querySelector('.event-action-edit'); 2139 if (editBtn) editBtn.click(); 2140 e.preventDefault(); 2141 } else if (e.key === 'Delete' || e.key === 'Backspace') { 2142 // Find and click the delete button 2143 var deleteBtn = focusedEvent.querySelector('.event-action-delete'); 2144 if (deleteBtn) deleteBtn.click(); 2145 e.preventDefault(); 2146 } 2147 } 2148 }); 2149 2150 // Conflict tooltip delegation (capture phase for mouseenter/leave) 2151 document.addEventListener('mouseenter', function(e) { 2152 if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) { 2153 showConflictTooltip(e.target); 2154 } 2155 }, true); 2156 2157 document.addEventListener('mouseleave', function(e) { 2158 if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) { 2159 hideConflictTooltip(); 2160 } 2161 }, true); 2162} // end delegation guard 2163 2164// Event panel navigation 2165window.navEventPanel = function(calId, year, month, namespace) { 2166 // Read exclude list from container data attribute 2167 const container = document.getElementById(calId); 2168 const exclude = container ? (container.dataset.exclude || '') : ''; 2169 2170 const params = new URLSearchParams({ 2171 call: 'plugin_calendar', 2172 action: 'load_month', 2173 year: year, 2174 month: month, 2175 namespace: namespace, 2176 exclude: exclude, 2177 _: new Date().getTime() // Cache buster 2178 }); 2179 2180 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 2181 method: 'POST', 2182 headers: { 2183 'Content-Type': 'application/x-www-form-urlencoded', 2184 'Cache-Control': 'no-cache, no-store, must-revalidate', 2185 'Pragma': 'no-cache' 2186 }, 2187 body: params.toString() 2188 }) 2189 .then(r => r.json()) 2190 .then(data => { 2191 if (data.success) { 2192 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 2193 } 2194 }) 2195 .catch(err => console.error('Error:', err)); 2196}; 2197 2198// Rebuild event panel only 2199window.rebuildEventPanel = function(calId, year, month, events, namespace) { 2200 const container = document.getElementById(calId); 2201 const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 2202 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 2203 2204 // Update month title in new compact header 2205 const monthTitle = container.querySelector('.panel-month-title'); 2206 if (monthTitle) { 2207 monthTitle.textContent = monthNames[month - 1] + ' ' + year; 2208 monthTitle.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 2209 monthTitle.setAttribute('title', 'Click to jump to month'); 2210 } 2211 2212 // Fallback: Update old header format if exists 2213 const oldHeader = container.querySelector('.panel-standalone-header h3, .calendar-month-picker'); 2214 if (oldHeader && !monthTitle) { 2215 oldHeader.textContent = monthNames[month - 1] + ' ' + year + ' Events'; 2216 oldHeader.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 2217 } 2218 2219 // Update nav buttons 2220 let prevMonth = month - 1; 2221 let prevYear = year; 2222 if (prevMonth < 1) { 2223 prevMonth = 12; 2224 prevYear--; 2225 } 2226 2227 let nextMonth = month + 1; 2228 let nextYear = year; 2229 if (nextMonth > 12) { 2230 nextMonth = 1; 2231 nextYear++; 2232 } 2233 2234 // Update new compact nav buttons 2235 const navBtns = container.querySelectorAll('.panel-nav-btn'); 2236 if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 2237 if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 2238 2239 // Fallback for old nav buttons 2240 const oldNavBtns = container.querySelectorAll('.cal-nav-btn'); 2241 if (oldNavBtns.length > 0 && navBtns.length === 0) { 2242 if (oldNavBtns[0]) oldNavBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 2243 if (oldNavBtns[1]) oldNavBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 2244 } 2245 2246 // Update Today button (works for both old and new) 2247 const todayBtn = container.querySelector('.panel-today-btn, .cal-today-btn, .cal-today-btn-compact'); 2248 if (todayBtn) { 2249 todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`); 2250 } 2251 2252 // Rebuild event list 2253 const eventList = container.querySelector('.event-list-compact'); 2254 if (eventList) { 2255 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 2256 } 2257}; 2258 2259// Open add event for panel 2260window.openAddEventPanel = function(calId, namespace) { 2261 const today = new Date(); 2262 const year = today.getFullYear(); 2263 const month = String(today.getMonth() + 1).padStart(2, '0'); 2264 const day = String(today.getDate()).padStart(2, '0'); 2265 const localDate = `${year}-${month}-${day}`; 2266 openAddEvent(calId, namespace, localDate); 2267}; 2268 2269// Toggle task completion 2270window.toggleTaskComplete = function(calId, eventId, date, namespace, completed) { 2271 const params = new URLSearchParams({ 2272 call: 'plugin_calendar', 2273 action: 'toggle_task', 2274 namespace: namespace, 2275 date: date, 2276 eventId: eventId, 2277 completed: completed ? '1' : '0', 2278 sectok: getSecurityToken() 2279 }); 2280 2281 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 2282 method: 'POST', 2283 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 2284 body: params.toString() 2285 }) 2286 .then(r => r.json()) 2287 .then(data => { 2288 if (data.success) { 2289 // Announce to screen readers 2290 announceToScreenReader(completed ? 'Task marked complete' : 'Task marked incomplete'); 2291 2292 const [year, month] = date.split('-').map(Number); 2293 2294 // Get the calendar's ORIGINAL namespace setting from the container 2295 const container = document.getElementById(calId); 2296 const calendarNamespace = container ? (container.dataset.namespace || '') : namespace; 2297 2298 reloadCalendarData(calId, year, month, calendarNamespace); 2299 } 2300 }) 2301 .catch(err => console.error('Error toggling task:', err)); 2302}; 2303 2304// Make dialog draggable 2305window.makeDialogDraggable = function(calId) { 2306 const dialog = document.getElementById('dialog-content-' + calId); 2307 const handle = document.getElementById('drag-handle-' + calId); 2308 2309 if (!dialog || !handle) return; 2310 2311 // Remove any existing drag setup to prevent duplicate listeners 2312 if (handle._dragCleanup) { 2313 handle._dragCleanup(); 2314 } 2315 2316 // Reset position when dialog opens 2317 dialog.style.transform = ''; 2318 2319 let isDragging = false; 2320 let currentX = 0; 2321 let currentY = 0; 2322 let initialX; 2323 let initialY; 2324 let xOffset = 0; 2325 let yOffset = 0; 2326 2327 function dragStart(e) { 2328 // Only start drag if clicking on the handle itself, not buttons inside it 2329 if (e.target.tagName === 'BUTTON') return; 2330 2331 initialX = e.clientX - xOffset; 2332 initialY = e.clientY - yOffset; 2333 isDragging = true; 2334 handle.style.cursor = 'grabbing'; 2335 } 2336 2337 function drag(e) { 2338 if (isDragging) { 2339 e.preventDefault(); 2340 currentX = e.clientX - initialX; 2341 currentY = e.clientY - initialY; 2342 xOffset = currentX; 2343 yOffset = currentY; 2344 dialog.style.transform = `translate(${currentX}px, ${currentY}px)`; 2345 } 2346 } 2347 2348 function dragEnd(e) { 2349 if (isDragging) { 2350 initialX = currentX; 2351 initialY = currentY; 2352 isDragging = false; 2353 handle.style.cursor = 'move'; 2354 } 2355 } 2356 2357 // Add listeners 2358 handle.addEventListener('mousedown', dragStart); 2359 document.addEventListener('mousemove', drag); 2360 document.addEventListener('mouseup', dragEnd); 2361 2362 // Store cleanup function to remove listeners later 2363 handle._dragCleanup = function() { 2364 handle.removeEventListener('mousedown', dragStart); 2365 document.removeEventListener('mousemove', drag); 2366 document.removeEventListener('mouseup', dragEnd); 2367 }; 2368}; 2369 2370// Toggle expand/collapse for past events 2371window.togglePastEventExpand = function(element) { 2372 // Stop propagation to prevent any parent click handlers 2373 event.stopPropagation(); 2374 2375 const meta = element.querySelector(".event-meta-compact"); 2376 const desc = element.querySelector(".event-desc-compact"); 2377 2378 // Toggle visibility 2379 if (meta.style.display === "none") { 2380 // Expand 2381 meta.style.display = "block"; 2382 if (desc) desc.style.display = "block"; 2383 element.classList.add("event-past-expanded"); 2384 } else { 2385 // Collapse 2386 meta.style.display = "none"; 2387 if (desc) desc.style.display = "none"; 2388 element.classList.remove("event-past-expanded"); 2389 } 2390}; 2391 2392// Filter calendar by namespace when clicking namespace badge (guarded) 2393if (!window._calendarClickDelegationInit) { 2394 window._calendarClickDelegationInit = true; 2395 document.addEventListener('click', function(e) { 2396 if (e.target.classList.contains('event-namespace-badge')) { 2397 const namespace = e.target.textContent; 2398 const calendar = e.target.closest('.calendar-compact-container'); 2399 2400 if (!calendar) return; 2401 2402 const calId = calendar.id; 2403 2404 // Use AJAX reload to filter both calendar grid and event list 2405 filterCalendarByNamespace(calId, namespace); 2406 } 2407 }); 2408} // end click delegation guard 2409 2410// Update the displayed filtered namespace in event list header 2411// Legacy badge removed - namespace filtering still works but badge no longer shown 2412window.updateFilteredNamespaceDisplay = function(calId, namespace) { 2413 const calendar = document.getElementById(calId); 2414 if (!calendar) return; 2415 2416 const headerContent = calendar.querySelector('.event-list-header-content'); 2417 if (!headerContent) return; 2418 2419 // Remove any existing filter badge (cleanup) 2420 let filterBadge = headerContent.querySelector('.namespace-filter-badge'); 2421 if (filterBadge) { 2422 filterBadge.remove(); 2423 } 2424}; 2425 2426// Clear namespace filter 2427window.clearNamespaceFilter = function(calId) { 2428 2429 const container = document.getElementById(calId); 2430 if (!container) { 2431 console.error('Calendar container not found:', calId); 2432 return; 2433 } 2434 2435 // Immediately hide/remove the filter badge 2436 const filterBadge = container.querySelector('.calendar-namespace-filter'); 2437 if (filterBadge) { 2438 filterBadge.style.display = 'none'; 2439 filterBadge.remove(); 2440 } 2441 2442 // Get current year and month 2443 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 2444 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 2445 2446 // Get original namespace (what the calendar was initialized with) 2447 const originalNamespace = container.dataset.originalNamespace || ''; 2448 2449 // Also check for sidebar widget 2450 const sidebarContainer = document.getElementById('sidebar-widget-' + calId); 2451 if (sidebarContainer) { 2452 // For sidebar widget, just reload the page without namespace filter 2453 // Remove the namespace from the URL and reload 2454 const url = new URL(window.location.href); 2455 url.searchParams.delete('namespace'); 2456 window.location.href = url.toString(); 2457 return; 2458 } 2459 2460 // For regular calendar, reload calendar with original namespace 2461 navCalendar(calId, year, month, originalNamespace); 2462}; 2463 2464window.clearNamespaceFilterPanel = function(calId) { 2465 2466 const container = document.getElementById(calId); 2467 if (!container) { 2468 console.error('Event panel container not found:', calId); 2469 return; 2470 } 2471 2472 // Get current year and month from URL params or container 2473 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 2474 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 2475 2476 // Get original namespace (what the panel was initialized with) 2477 const originalNamespace = container.dataset.originalNamespace || ''; 2478 2479 2480 // Reload event panel with original namespace 2481 navEventPanel(calId, year, month, originalNamespace); 2482}; 2483 2484// Color picker functions 2485window.updateCustomColorPicker = function(calId) { 2486 const select = document.getElementById('event-color-' + calId); 2487 const picker = document.getElementById('event-color-custom-' + calId); 2488 2489 if (select.value === 'custom') { 2490 // Show color picker 2491 picker.style.display = 'inline-block'; 2492 picker.click(); // Open color picker 2493 } else { 2494 // Hide color picker and sync value 2495 picker.style.display = 'none'; 2496 picker.value = select.value; 2497 } 2498}; 2499 2500function updateColorFromPicker(calId) { 2501 const select = document.getElementById('event-color-' + calId); 2502 const picker = document.getElementById('event-color-custom-' + calId); 2503 2504 // Set select to custom and update its underlying value 2505 select.value = 'custom'; 2506 // Store the actual color value in a data attribute 2507 select.dataset.customColor = picker.value; 2508} 2509 2510// Toggle past events visibility 2511window.togglePastEvents = function(calId) { 2512 const content = document.getElementById('past-events-' + calId); 2513 const arrow = document.getElementById('past-arrow-' + calId); 2514 2515 if (!content || !arrow) { 2516 console.error('Past events elements not found for:', calId); 2517 return; 2518 } 2519 2520 // Check computed style instead of inline style 2521 const isHidden = window.getComputedStyle(content).display === 'none'; 2522 2523 if (isHidden) { 2524 content.style.display = 'block'; 2525 arrow.textContent = '▼'; 2526 } else { 2527 content.style.display = 'none'; 2528 arrow.textContent = '▶'; 2529 } 2530}; 2531 2532// Fuzzy match scoring function 2533window.fuzzyMatch = function(pattern, str) { 2534 pattern = pattern.toLowerCase(); 2535 str = str.toLowerCase(); 2536 2537 let patternIdx = 0; 2538 let score = 0; 2539 let consecutiveMatches = 0; 2540 2541 for (let i = 0; i < str.length; i++) { 2542 if (patternIdx < pattern.length && str[i] === pattern[patternIdx]) { 2543 score += 1 + consecutiveMatches; 2544 consecutiveMatches++; 2545 patternIdx++; 2546 } else { 2547 consecutiveMatches = 0; 2548 } 2549 } 2550 2551 // Return null if not all characters matched 2552 if (patternIdx !== pattern.length) { 2553 return null; 2554 } 2555 2556 // Bonus for exact match 2557 if (str === pattern) { 2558 score += 100; 2559 } 2560 2561 // Bonus for starts with 2562 if (str.startsWith(pattern)) { 2563 score += 50; 2564 } 2565 2566 return score; 2567}; 2568 2569// Initialize namespace search for a calendar 2570window.initNamespaceSearch = function(calId) { 2571 const searchInput = document.getElementById('event-namespace-search-' + calId); 2572 const hiddenInput = document.getElementById('event-namespace-' + calId); 2573 const dropdown = document.getElementById('event-namespace-dropdown-' + calId); 2574 const dataElement = document.getElementById('namespaces-data-' + calId); 2575 2576 if (!searchInput || !hiddenInput || !dropdown || !dataElement) { 2577 return; // Elements not found 2578 } 2579 2580 // PERFORMANCE FIX: Prevent re-binding event listeners on each dialog open 2581 if (searchInput.dataset.initialized === 'true') { 2582 return; 2583 } 2584 searchInput.dataset.initialized = 'true'; 2585 2586 let namespaces = []; 2587 try { 2588 namespaces = JSON.parse(dataElement.textContent); 2589 } catch (e) { 2590 console.error('Failed to parse namespaces data:', e); 2591 return; 2592 } 2593 2594 let selectedIndex = -1; 2595 2596 // Filter and show dropdown 2597 function filterNamespaces(query) { 2598 if (!query || query.trim() === '') { 2599 // Show all namespaces when empty 2600 hiddenInput.value = ''; 2601 const results = namespaces.slice(0, 20); // Limit to 20 2602 showDropdown(results); 2603 return; 2604 } 2605 2606 // Fuzzy match and score 2607 const matches = []; 2608 for (let i = 0; i < namespaces.length; i++) { 2609 const score = fuzzyMatch(query, namespaces[i]); 2610 if (score !== null) { 2611 matches.push({ namespace: namespaces[i], score: score }); 2612 } 2613 } 2614 2615 // Sort by score (descending) 2616 matches.sort((a, b) => b.score - a.score); 2617 2618 // Take top 20 results 2619 const results = matches.slice(0, 20).map(m => m.namespace); 2620 showDropdown(results); 2621 } 2622 2623 function showDropdown(results) { 2624 dropdown.innerHTML = ''; 2625 selectedIndex = -1; 2626 2627 if (results.length === 0) { 2628 dropdown.style.display = 'none'; 2629 return; 2630 } 2631 2632 // Add (default) option 2633 const defaultOption = document.createElement('div'); 2634 defaultOption.className = 'namespace-option'; 2635 defaultOption.textContent = '(default)'; 2636 defaultOption.dataset.value = ''; 2637 dropdown.appendChild(defaultOption); 2638 2639 results.forEach(ns => { 2640 const option = document.createElement('div'); 2641 option.className = 'namespace-option'; 2642 option.textContent = ns; 2643 option.dataset.value = ns; 2644 dropdown.appendChild(option); 2645 }); 2646 2647 dropdown.style.display = 'block'; 2648 } 2649 2650 function hideDropdown() { 2651 dropdown.style.display = 'none'; 2652 selectedIndex = -1; 2653 } 2654 2655 function selectOption(namespace) { 2656 hiddenInput.value = namespace; 2657 searchInput.value = namespace || '(default)'; 2658 hideDropdown(); 2659 } 2660 2661 // Event listeners - only bound once now 2662 searchInput.addEventListener('input', function(e) { 2663 filterNamespaces(e.target.value); 2664 }); 2665 2666 searchInput.addEventListener('focus', function(e) { 2667 filterNamespaces(e.target.value); 2668 }); 2669 2670 searchInput.addEventListener('blur', function(e) { 2671 // Delay to allow click on dropdown 2672 setTimeout(hideDropdown, 200); 2673 }); 2674 2675 searchInput.addEventListener('keydown', function(e) { 2676 const options = dropdown.querySelectorAll('.namespace-option'); 2677 2678 if (e.key === 'ArrowDown') { 2679 e.preventDefault(); 2680 selectedIndex = Math.min(selectedIndex + 1, options.length - 1); 2681 updateSelection(options); 2682 } else if (e.key === 'ArrowUp') { 2683 e.preventDefault(); 2684 selectedIndex = Math.max(selectedIndex - 1, -1); 2685 updateSelection(options); 2686 } else if (e.key === 'Enter') { 2687 e.preventDefault(); 2688 if (selectedIndex >= 0 && options[selectedIndex]) { 2689 selectOption(options[selectedIndex].dataset.value); 2690 } 2691 } else if (e.key === 'Escape') { 2692 hideDropdown(); 2693 } 2694 }); 2695 2696 function updateSelection(options) { 2697 options.forEach((opt, idx) => { 2698 if (idx === selectedIndex) { 2699 opt.classList.add('selected'); 2700 opt.scrollIntoView({ block: 'nearest' }); 2701 } else { 2702 opt.classList.remove('selected'); 2703 } 2704 }); 2705 } 2706 2707 // Click on dropdown option 2708 dropdown.addEventListener('mousedown', function(e) { 2709 if (e.target.classList.contains('namespace-option')) { 2710 selectOption(e.target.dataset.value); 2711 } 2712 }); 2713}; 2714 2715// Legacy function - kept for compatibility, now handled by custom pickers 2716window.updateEndTimeOptions = function(calId) { 2717 updateEndTimeButtonState(calId); 2718}; 2719 2720// ============================================================================ 2721// CUSTOM TIME PICKER - Fast, lightweight time selection 2722// ============================================================================ 2723 2724// Time data - generated once, reused for all pickers 2725window._calendarTimeData = null; 2726window.getTimeData = function() { 2727 if (window._calendarTimeData) return window._calendarTimeData; 2728 2729 const periods = [ 2730 { name: 'Morning', hours: [6, 7, 8, 9, 10, 11] }, 2731 { name: 'Afternoon', hours: [12, 13, 14, 15, 16, 17] }, 2732 { name: 'Evening', hours: [18, 19, 20, 21, 22, 23] }, 2733 { name: 'Night', hours: [0, 1, 2, 3, 4, 5] } 2734 ]; 2735 2736 const data = []; 2737 periods.forEach(period => { 2738 const times = []; 2739 period.hours.forEach(hour => { 2740 for (let minute = 0; minute < 60; minute += 15) { 2741 const value = String(hour).padStart(2, '0') + ':' + String(minute).padStart(2, '0'); 2742 const displayHour = hour === 0 ? 12 : (hour > 12 ? hour - 12 : hour); 2743 const ampm = hour < 12 ? 'AM' : 'PM'; 2744 const display = displayHour + ':' + String(minute).padStart(2, '0') + ' ' + ampm; 2745 const minutes = hour * 60 + minute; 2746 times.push({ value, display, minutes }); 2747 } 2748 }); 2749 data.push({ name: period.name, times }); 2750 }); 2751 2752 window._calendarTimeData = data; 2753 return data; 2754}; 2755 2756// Format time value to display string 2757window.formatTimeDisplay = function(value) { 2758 if (!value) return ''; 2759 const [hour, minute] = value.split(':').map(Number); 2760 const displayHour = hour === 0 ? 12 : (hour > 12 ? hour - 12 : hour); 2761 const ampm = hour < 12 ? 'AM' : 'PM'; 2762 return displayHour + ':' + String(minute).padStart(2, '0') + ' ' + ampm; 2763}; 2764 2765// Build dropdown HTML - called only when opening 2766window.buildTimeDropdown = function(calId, isEndTime, startTimeValue, isMultiDay) { 2767 const data = getTimeData(); 2768 let html = ''; 2769 2770 // Calculate start time minutes for filtering end time options 2771 let startMinutes = -1; 2772 if (isEndTime && startTimeValue && !isMultiDay) { 2773 const [h, m] = startTimeValue.split(':').map(Number); 2774 startMinutes = h * 60 + m; 2775 } 2776 2777 // Add "All day" / "Same as start" option 2778 const defaultText = isEndTime ? 'Same as start' : 'All day'; 2779 html += '<div class="time-option" data-value="">' + defaultText + '</div>'; 2780 2781 data.forEach(period => { 2782 html += '<div class="time-dropdown-section">'; 2783 html += '<div class="time-dropdown-header">' + period.name + '</div>'; 2784 period.times.forEach(time => { 2785 const disabled = (isEndTime && !isMultiDay && startMinutes >= 0 && time.minutes <= startMinutes); 2786 const disabledClass = disabled ? ' disabled' : ''; 2787 html += '<div class="time-option' + disabledClass + '" data-value="' + time.value + '" data-minutes="' + time.minutes + '">' + time.display + '</div>'; 2788 }); 2789 html += '</div>'; 2790 }); 2791 2792 return html; 2793}; 2794 2795// Open time dropdown 2796window.openTimeDropdown = function(calId, isEndTime) { 2797 const btnId = isEndTime ? 'end-time-picker-btn-' + calId : 'time-picker-btn-' + calId; 2798 const dropdownId = isEndTime ? 'end-time-dropdown-' + calId : 'time-dropdown-' + calId; 2799 const btn = document.getElementById(btnId); 2800 const dropdown = document.getElementById(dropdownId); 2801 2802 if (!btn || !dropdown) return; 2803 2804 // Close any other open dropdowns first 2805 document.querySelectorAll('.time-dropdown.open').forEach(d => { 2806 if (d.id !== dropdownId) { 2807 d.classList.remove('open'); 2808 d.innerHTML = ''; 2809 } 2810 }); 2811 document.querySelectorAll('.custom-time-picker.open').forEach(b => { 2812 if (b.id !== btnId) b.classList.remove('open'); 2813 }); 2814 2815 // Toggle this dropdown 2816 if (dropdown.classList.contains('open')) { 2817 dropdown.classList.remove('open'); 2818 btn.classList.remove('open'); 2819 dropdown.innerHTML = ''; 2820 return; 2821 } 2822 2823 // Get current state 2824 const startTimeInput = document.getElementById('event-time-' + calId); 2825 const startDateInput = document.getElementById('event-date-' + calId); 2826 const endDateInput = document.getElementById('event-end-date-' + calId); 2827 2828 const startTime = startTimeInput ? startTimeInput.value : ''; 2829 const startDate = startDateInput ? startDateInput.value : ''; 2830 const endDate = endDateInput ? endDateInput.value : ''; 2831 const isMultiDay = endDate && endDate !== startDate; 2832 2833 // Build and show dropdown 2834 dropdown.innerHTML = buildTimeDropdown(calId, isEndTime, startTime, isMultiDay); 2835 dropdown.classList.add('open'); 2836 btn.classList.add('open'); 2837 2838 // Scroll to appropriate option 2839 const currentValue = isEndTime ? 2840 document.getElementById('event-end-time-' + calId).value : 2841 document.getElementById('event-time-' + calId).value; 2842 2843 if (currentValue) { 2844 // Scroll to selected option 2845 const selected = dropdown.querySelector('[data-value="' + currentValue + '"]'); 2846 if (selected) { 2847 selected.classList.add('selected'); 2848 selected.scrollIntoView({ block: 'center', behavior: 'instant' }); 2849 } 2850 } else if (isEndTime && startTime) { 2851 // For end time with no selection, scroll to first available option after start time 2852 const firstAvailable = dropdown.querySelector('.time-option:not(.disabled):not([data-value=""])'); 2853 if (firstAvailable) { 2854 firstAvailable.scrollIntoView({ block: 'center', behavior: 'instant' }); 2855 } 2856 } 2857}; 2858 2859// Select time option 2860window.selectTimeOption = function(calId, isEndTime, value) { 2861 const inputId = isEndTime ? 'event-end-time-' + calId : 'event-time-' + calId; 2862 const btnId = isEndTime ? 'end-time-picker-btn-' + calId : 'time-picker-btn-' + calId; 2863 const dropdownId = isEndTime ? 'end-time-dropdown-' + calId : 'time-dropdown-' + calId; 2864 2865 const input = document.getElementById(inputId); 2866 const btn = document.getElementById(btnId); 2867 const dropdown = document.getElementById(dropdownId); 2868 2869 if (input) { 2870 input.value = value; 2871 } 2872 2873 if (btn) { 2874 const display = btn.querySelector('.time-display'); 2875 if (display) { 2876 if (value) { 2877 display.textContent = formatTimeDisplay(value); 2878 } else { 2879 display.textContent = isEndTime ? 'Same as start' : 'All day'; 2880 } 2881 } 2882 btn.classList.remove('open'); 2883 } 2884 2885 if (dropdown) { 2886 dropdown.classList.remove('open'); 2887 dropdown.innerHTML = ''; 2888 } 2889 2890 // If start time changed, update end time button state 2891 if (!isEndTime) { 2892 updateEndTimeButtonState(calId); 2893 } 2894}; 2895 2896// Update end time button enabled/disabled state 2897window.updateEndTimeButtonState = function(calId) { 2898 const startTimeInput = document.getElementById('event-time-' + calId); 2899 const endTimeBtn = document.getElementById('end-time-picker-btn-' + calId); 2900 const endTimeInput = document.getElementById('event-end-time-' + calId); 2901 2902 if (!startTimeInput || !endTimeBtn) return; 2903 2904 const startTime = startTimeInput.value; 2905 2906 if (!startTime) { 2907 // All day - disable end time 2908 endTimeBtn.disabled = true; 2909 if (endTimeInput) endTimeInput.value = ''; 2910 const display = endTimeBtn.querySelector('.time-display'); 2911 if (display) display.textContent = 'Same as start'; 2912 } else { 2913 endTimeBtn.disabled = false; 2914 } 2915}; 2916 2917// Initialize custom time pickers for a dialog 2918window.initCustomTimePickers = function(calId) { 2919 const startBtn = document.getElementById('time-picker-btn-' + calId); 2920 const endBtn = document.getElementById('end-time-picker-btn-' + calId); 2921 const startDropdown = document.getElementById('time-dropdown-' + calId); 2922 const endDropdown = document.getElementById('end-time-dropdown-' + calId); 2923 2924 // Prevent re-initialization 2925 if (startBtn && startBtn.dataset.initialized) return; 2926 2927 if (startBtn) { 2928 startBtn.dataset.initialized = 'true'; 2929 startBtn.addEventListener('click', function(e) { 2930 e.preventDefault(); 2931 e.stopPropagation(); 2932 openTimeDropdown(calId, false); 2933 }); 2934 } 2935 2936 if (endBtn) { 2937 endBtn.addEventListener('click', function(e) { 2938 e.preventDefault(); 2939 e.stopPropagation(); 2940 if (!endBtn.disabled) { 2941 openTimeDropdown(calId, true); 2942 } 2943 }); 2944 } 2945 2946 // Handle clicks on time options 2947 if (startDropdown) { 2948 startDropdown.addEventListener('click', function(e) { 2949 const option = e.target.closest('.time-option'); 2950 if (option && !option.classList.contains('disabled')) { 2951 e.stopPropagation(); 2952 selectTimeOption(calId, false, option.dataset.value); 2953 } 2954 }); 2955 } 2956 2957 if (endDropdown) { 2958 endDropdown.addEventListener('click', function(e) { 2959 const option = e.target.closest('.time-option'); 2960 if (option && !option.classList.contains('disabled')) { 2961 e.stopPropagation(); 2962 selectTimeOption(calId, true, option.dataset.value); 2963 } 2964 }); 2965 } 2966 2967 // Handle date changes - update end time options when dates change 2968 const startDateInput = document.getElementById('event-date-' + calId); 2969 const endDateInput = document.getElementById('event-end-date-' + calId); 2970 2971 if (startDateInput && !startDateInput.dataset.initialized) { 2972 startDateInput.dataset.initialized = 'true'; 2973 startDateInput.addEventListener('change', function() { 2974 // Just close any open dropdowns - they'll rebuild with correct state when reopened 2975 const dropdown = document.getElementById('end-time-dropdown-' + calId); 2976 if (dropdown && dropdown.classList.contains('open')) { 2977 dropdown.classList.remove('open'); 2978 dropdown.innerHTML = ''; 2979 } 2980 }); 2981 } 2982 2983 if (endDateInput && !endDateInput.dataset.initialized) { 2984 endDateInput.dataset.initialized = 'true'; 2985 endDateInput.addEventListener('change', function() { 2986 const dropdown = document.getElementById('end-time-dropdown-' + calId); 2987 if (dropdown && dropdown.classList.contains('open')) { 2988 dropdown.classList.remove('open'); 2989 dropdown.innerHTML = ''; 2990 } 2991 }); 2992 } 2993}; 2994 2995// Close dropdowns when clicking outside 2996if (!window._calendarDropdownCloseInit) { 2997 window._calendarDropdownCloseInit = true; 2998 document.addEventListener('click', function(e) { 2999 // Don't close if clicking inside a picker button or dropdown 3000 if (e.target.closest('.custom-time-picker') || e.target.closest('.time-dropdown') || 3001 e.target.closest('.custom-date-picker') || e.target.closest('.date-dropdown')) { 3002 return; 3003 } 3004 3005 // Close all open time dropdowns 3006 document.querySelectorAll('.time-dropdown.open').forEach(d => { 3007 d.classList.remove('open'); 3008 d.innerHTML = ''; 3009 }); 3010 document.querySelectorAll('.custom-time-picker.open').forEach(b => { 3011 b.classList.remove('open'); 3012 }); 3013 3014 // Close all open date dropdowns 3015 document.querySelectorAll('.date-dropdown.open').forEach(d => { 3016 d.classList.remove('open'); 3017 d.innerHTML = ''; 3018 }); 3019 document.querySelectorAll('.custom-date-picker.open').forEach(b => { 3020 b.classList.remove('open'); 3021 }); 3022 }); 3023} 3024 3025// Set time picker value programmatically (for edit mode) 3026window.setTimePicker = function(calId, isEndTime, value) { 3027 const inputId = isEndTime ? 'event-end-time-' + calId : 'event-time-' + calId; 3028 const btnId = isEndTime ? 'end-time-picker-btn-' + calId : 'time-picker-btn-' + calId; 3029 3030 const input = document.getElementById(inputId); 3031 const btn = document.getElementById(btnId); 3032 3033 if (input) { 3034 input.value = value || ''; 3035 } 3036 3037 if (btn) { 3038 const display = btn.querySelector('.time-display'); 3039 if (display) { 3040 if (value) { 3041 display.textContent = formatTimeDisplay(value); 3042 } else { 3043 display.textContent = isEndTime ? 'Same as start' : 'All day'; 3044 } 3045 } 3046 3047 // Update disabled state for end time 3048 if (isEndTime) { 3049 const startTimeInput = document.getElementById('event-time-' + calId); 3050 btn.disabled = !startTimeInput || !startTimeInput.value; 3051 } 3052 } 3053}; 3054 3055// ============================================================================ 3056// CUSTOM DATE PICKER - Fast, lightweight date selection 3057// ============================================================================ 3058 3059// Format date for display 3060window.formatDateDisplay = function(dateStr) { 3061 if (!dateStr) return ''; 3062 const date = new Date(dateStr + 'T00:00:00'); 3063 return date.toLocaleDateString('en-US', { 3064 weekday: 'short', 3065 month: 'short', 3066 day: 'numeric', 3067 year: 'numeric' 3068 }); 3069}; 3070 3071// Build date picker calendar HTML 3072window.buildDateCalendar = function(calId, isEndDate, year, month, selectedDate, minDate) { 3073 const today = new Date(); 3074 today.setHours(0, 0, 0, 0); 3075 3076 const firstDay = new Date(year, month, 1); 3077 const lastDay = new Date(year, month + 1, 0); 3078 const startDayOfWeek = firstDay.getDay(); 3079 const daysInMonth = lastDay.getDate(); 3080 3081 const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 3082 'July', 'August', 'September', 'October', 'November', 'December']; 3083 3084 let html = '<div class="date-picker-calendar">'; 3085 3086 // Header with navigation 3087 html += '<div class="date-picker-header">'; 3088 html += '<button type="button" class="date-picker-nav" data-action="prev">◀</button>'; 3089 html += '<span class="date-picker-title">' + monthNames[month] + ' ' + year + '</span>'; 3090 html += '<button type="button" class="date-picker-nav" data-action="next">▶</button>'; 3091 html += '</div>'; 3092 3093 // Weekday headers 3094 html += '<div class="date-picker-weekdays">'; 3095 ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].forEach(d => { 3096 html += '<div class="date-picker-weekday">' + d + '</div>'; 3097 }); 3098 html += '</div>'; 3099 3100 // Days grid 3101 html += '<div class="date-picker-days">'; 3102 3103 // Previous month days 3104 const prevMonth = new Date(year, month, 0); 3105 const prevMonthDays = prevMonth.getDate(); 3106 for (let i = startDayOfWeek - 1; i >= 0; i--) { 3107 const day = prevMonthDays - i; 3108 const dateStr = formatDateValue(year, month - 1, day); 3109 html += '<button type="button" class="date-picker-day other-month" data-date="' + dateStr + '">' + day + '</button>'; 3110 } 3111 3112 // Current month days 3113 for (let day = 1; day <= daysInMonth; day++) { 3114 const dateStr = formatDateValue(year, month, day); 3115 const dateObj = new Date(year, month, day); 3116 dateObj.setHours(0, 0, 0, 0); 3117 3118 let classes = 'date-picker-day'; 3119 if (dateObj.getTime() === today.getTime()) classes += ' today'; 3120 if (dateStr === selectedDate) classes += ' selected'; 3121 3122 // For end date, disable dates before start date 3123 if (isEndDate && minDate) { 3124 const minDateObj = new Date(minDate + 'T00:00:00'); 3125 if (dateObj < minDateObj) classes += ' disabled'; 3126 } 3127 3128 html += '<button type="button" class="' + classes + '" data-date="' + dateStr + '">' + day + '</button>'; 3129 } 3130 3131 // Next month days to fill grid 3132 const totalCells = startDayOfWeek + daysInMonth; 3133 const remainingCells = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7); 3134 for (let i = 1; i <= remainingCells; i++) { 3135 const dateStr = formatDateValue(year, month + 1, i); 3136 html += '<button type="button" class="date-picker-day other-month" data-date="' + dateStr + '">' + i + '</button>'; 3137 } 3138 3139 html += '</div>'; 3140 3141 // Clear button for end date 3142 if (isEndDate) { 3143 html += '<button type="button" class="date-picker-clear" data-action="clear">Clear End Date</button>'; 3144 } 3145 3146 html += '</div>'; 3147 return html; 3148}; 3149 3150// Format date value as YYYY-MM-DD 3151window.formatDateValue = function(year, month, day) { 3152 // Handle month overflow 3153 const date = new Date(year, month, day); 3154 const y = date.getFullYear(); 3155 const m = String(date.getMonth() + 1).padStart(2, '0'); 3156 const d = String(date.getDate()).padStart(2, '0'); 3157 return y + '-' + m + '-' + d; 3158}; 3159 3160// Open date dropdown 3161window.openDateDropdown = function(calId, isEndDate) { 3162 const btnId = isEndDate ? 'end-date-picker-btn-' + calId : 'date-picker-btn-' + calId; 3163 const dropdownId = isEndDate ? 'end-date-dropdown-' + calId : 'date-dropdown-' + calId; 3164 const btn = document.getElementById(btnId); 3165 const dropdown = document.getElementById(dropdownId); 3166 3167 if (!btn || !dropdown) return; 3168 3169 // Close any other open dropdowns first 3170 document.querySelectorAll('.date-dropdown.open, .time-dropdown.open').forEach(d => { 3171 if (d.id !== dropdownId) { 3172 d.classList.remove('open'); 3173 d.innerHTML = ''; 3174 } 3175 }); 3176 document.querySelectorAll('.custom-date-picker.open, .custom-time-picker.open').forEach(b => { 3177 if (b.id !== btnId) b.classList.remove('open'); 3178 }); 3179 3180 // Toggle this dropdown 3181 if (dropdown.classList.contains('open')) { 3182 dropdown.classList.remove('open'); 3183 btn.classList.remove('open'); 3184 dropdown.innerHTML = ''; 3185 return; 3186 } 3187 3188 // Get current value and min date 3189 const inputId = isEndDate ? 'event-end-date-' + calId : 'event-date-' + calId; 3190 const input = document.getElementById(inputId); 3191 const selectedDate = input ? input.value : ''; 3192 3193 let minDate = null; 3194 if (isEndDate) { 3195 const startInput = document.getElementById('event-date-' + calId); 3196 minDate = startInput ? startInput.value : null; 3197 } 3198 3199 // Determine which month to show 3200 let year, month; 3201 if (selectedDate) { 3202 // If there's a selected date, show that month 3203 const d = new Date(selectedDate + 'T00:00:00'); 3204 year = d.getFullYear(); 3205 month = d.getMonth(); 3206 } else if (isEndDate && minDate) { 3207 // For end date with no value, start on the start date's month 3208 const d = new Date(minDate + 'T00:00:00'); 3209 year = d.getFullYear(); 3210 month = d.getMonth(); 3211 } else { 3212 // Fallback to current month 3213 const now = new Date(); 3214 year = now.getFullYear(); 3215 month = now.getMonth(); 3216 } 3217 3218 // Store current view state 3219 dropdown.dataset.year = year; 3220 dropdown.dataset.month = month; 3221 dropdown.dataset.isEnd = isEndDate ? '1' : '0'; 3222 dropdown.dataset.calId = calId; 3223 3224 // Build and show 3225 dropdown.innerHTML = buildDateCalendar(calId, isEndDate, year, month, selectedDate, minDate); 3226 dropdown.classList.add('open'); 3227 btn.classList.add('open'); 3228}; 3229 3230// Select date 3231window.selectDate = function(calId, isEndDate, dateStr) { 3232 const inputId = isEndDate ? 'event-end-date-' + calId : 'event-date-' + calId; 3233 const btnId = isEndDate ? 'end-date-picker-btn-' + calId : 'date-picker-btn-' + calId; 3234 const dropdownId = isEndDate ? 'end-date-dropdown-' + calId : 'date-dropdown-' + calId; 3235 3236 const input = document.getElementById(inputId); 3237 const btn = document.getElementById(btnId); 3238 const dropdown = document.getElementById(dropdownId); 3239 3240 if (input) { 3241 input.value = dateStr || ''; 3242 } 3243 3244 if (btn) { 3245 const display = btn.querySelector('.date-display'); 3246 if (display) { 3247 display.textContent = dateStr ? formatDateDisplay(dateStr) : (isEndDate ? 'Optional' : 'Select date'); 3248 } 3249 btn.classList.remove('open'); 3250 } 3251 3252 if (dropdown) { 3253 dropdown.classList.remove('open'); 3254 dropdown.innerHTML = ''; 3255 } 3256}; 3257 3258// Navigate date picker month 3259window.navigateDatePicker = function(dropdown, direction) { 3260 let year = parseInt(dropdown.dataset.year); 3261 let month = parseInt(dropdown.dataset.month); 3262 const isEndDate = dropdown.dataset.isEnd === '1'; 3263 const calId = dropdown.dataset.calId; 3264 3265 month += direction; 3266 if (month < 0) { month = 11; year--; } 3267 if (month > 11) { month = 0; year++; } 3268 3269 dropdown.dataset.year = year; 3270 dropdown.dataset.month = month; 3271 3272 const inputId = isEndDate ? 'event-end-date-' + calId : 'event-date-' + calId; 3273 const input = document.getElementById(inputId); 3274 const selectedDate = input ? input.value : ''; 3275 3276 let minDate = null; 3277 if (isEndDate) { 3278 const startInput = document.getElementById('event-date-' + calId); 3279 minDate = startInput ? startInput.value : null; 3280 } 3281 3282 dropdown.innerHTML = buildDateCalendar(calId, isEndDate, year, month, selectedDate, minDate); 3283}; 3284 3285// Initialize custom date pickers for a dialog 3286window.initCustomDatePickers = function(calId) { 3287 const startBtn = document.getElementById('date-picker-btn-' + calId); 3288 const endBtn = document.getElementById('end-date-picker-btn-' + calId); 3289 const startDropdown = document.getElementById('date-dropdown-' + calId); 3290 const endDropdown = document.getElementById('end-date-dropdown-' + calId); 3291 3292 // Prevent re-initialization 3293 if (startBtn && startBtn.dataset.initialized) return; 3294 3295 if (startBtn) { 3296 startBtn.dataset.initialized = 'true'; 3297 startBtn.addEventListener('click', function(e) { 3298 e.preventDefault(); 3299 e.stopPropagation(); 3300 openDateDropdown(calId, false); 3301 }); 3302 } 3303 3304 if (endBtn) { 3305 endBtn.addEventListener('click', function(e) { 3306 e.preventDefault(); 3307 e.stopPropagation(); 3308 openDateDropdown(calId, true); 3309 }); 3310 } 3311 3312 // Handle clicks inside date dropdowns 3313 [startDropdown, endDropdown].forEach((dropdown, idx) => { 3314 if (!dropdown) return; 3315 const isEnd = idx === 1; 3316 3317 dropdown.addEventListener('click', function(e) { 3318 e.stopPropagation(); 3319 3320 const nav = e.target.closest('.date-picker-nav'); 3321 if (nav) { 3322 const direction = nav.dataset.action === 'prev' ? -1 : 1; 3323 navigateDatePicker(dropdown, direction); 3324 return; 3325 } 3326 3327 const clear = e.target.closest('.date-picker-clear'); 3328 if (clear) { 3329 selectDate(calId, true, ''); 3330 return; 3331 } 3332 3333 const day = e.target.closest('.date-picker-day'); 3334 if (day && !day.classList.contains('disabled')) { 3335 selectDate(calId, isEnd, day.dataset.date); 3336 } 3337 }); 3338 }); 3339}; 3340 3341// Set date picker value programmatically 3342window.setDatePicker = function(calId, isEndDate, value) { 3343 const inputId = isEndDate ? 'event-end-date-' + calId : 'event-date-' + calId; 3344 const btnId = isEndDate ? 'end-date-picker-btn-' + calId : 'date-picker-btn-' + calId; 3345 3346 const input = document.getElementById(inputId); 3347 const btn = document.getElementById(btnId); 3348 3349 if (input) { 3350 input.value = value || ''; 3351 } 3352 3353 if (btn) { 3354 const display = btn.querySelector('.date-display'); 3355 if (display) { 3356 display.textContent = value ? formatDateDisplay(value) : (isEndDate ? 'Optional' : 'Select date'); 3357 } 3358 } 3359}; 3360 3361// Check for time conflicts between events on the same date 3362window.checkTimeConflicts = function(events, currentEventId) { 3363 const conflicts = []; 3364 3365 // Group events by date 3366 const eventsByDate = {}; 3367 for (const [date, dateEvents] of Object.entries(events)) { 3368 if (!Array.isArray(dateEvents)) continue; 3369 3370 dateEvents.forEach(evt => { 3371 if (!evt.time || evt.id === currentEventId) return; // Skip all-day events and current event 3372 3373 if (!eventsByDate[date]) eventsByDate[date] = []; 3374 eventsByDate[date].push(evt); 3375 }); 3376 } 3377 3378 // Check for overlaps on each date 3379 for (const [date, dateEvents] of Object.entries(eventsByDate)) { 3380 for (let i = 0; i < dateEvents.length; i++) { 3381 for (let j = i + 1; j < dateEvents.length; j++) { 3382 const evt1 = dateEvents[i]; 3383 const evt2 = dateEvents[j]; 3384 3385 if (eventsOverlap(evt1, evt2)) { 3386 // Mark both events as conflicting 3387 if (!evt1.hasConflict) evt1.hasConflict = true; 3388 if (!evt2.hasConflict) evt2.hasConflict = true; 3389 3390 // Store conflict info 3391 if (!evt1.conflictsWith) evt1.conflictsWith = []; 3392 if (!evt2.conflictsWith) evt2.conflictsWith = []; 3393 3394 evt1.conflictsWith.push({id: evt2.id, title: evt2.title, time: evt2.time, endTime: evt2.endTime}); 3395 evt2.conflictsWith.push({id: evt1.id, title: evt1.title, time: evt1.time, endTime: evt1.endTime}); 3396 } 3397 } 3398 } 3399 } 3400 3401 return events; 3402}; 3403 3404// Check if two events overlap in time 3405function eventsOverlap(evt1, evt2) { 3406 if (!evt1.time || !evt2.time) return false; // All-day events don't conflict 3407 3408 const start1 = evt1.time; 3409 const end1 = evt1.endTime || evt1.time; // If no end time, treat as same as start 3410 3411 const start2 = evt2.time; 3412 const end2 = evt2.endTime || evt2.time; 3413 3414 // Convert to minutes for easier comparison 3415 const start1Mins = timeToMinutes(start1); 3416 const end1Mins = timeToMinutes(end1); 3417 const start2Mins = timeToMinutes(start2); 3418 const end2Mins = timeToMinutes(end2); 3419 3420 // Check for overlap 3421 // Events overlap if: start1 < end2 AND start2 < end1 3422 return start1Mins < end2Mins && start2Mins < end1Mins; 3423} 3424 3425// Convert HH:MM time to minutes since midnight 3426function timeToMinutes(timeStr) { 3427 const [hours, minutes] = timeStr.split(':').map(Number); 3428 return hours * 60 + minutes; 3429} 3430 3431// Format time range for display 3432window.formatTimeRange = function(startTime, endTime) { 3433 if (!startTime) return ''; 3434 3435 const formatTime = (timeStr) => { 3436 const [hour24, minute] = timeStr.split(':').map(Number); 3437 const hour12 = hour24 === 0 ? 12 : (hour24 > 12 ? hour24 - 12 : hour24); 3438 const ampm = hour24 < 12 ? 'AM' : 'PM'; 3439 return hour12 + ':' + String(minute).padStart(2, '0') + ' ' + ampm; 3440 }; 3441 3442 if (!endTime || endTime === startTime) { 3443 return formatTime(startTime); 3444 } 3445 3446 return formatTime(startTime) + ' - ' + formatTime(endTime); 3447}; 3448 3449// Track last known mouse position for tooltip positioning fallback 3450var _lastMouseX = 0, _lastMouseY = 0; 3451document.addEventListener('mousemove', function(e) { 3452 _lastMouseX = e.clientX; 3453 _lastMouseY = e.clientY; 3454}); 3455 3456// Show custom conflict tooltip 3457window.showConflictTooltip = function(badgeElement) { 3458 // Remove any existing tooltip 3459 hideConflictTooltip(); 3460 3461 // Get conflict data (base64-encoded JSON to avoid attribute quote issues) 3462 const conflictsRaw = badgeElement.getAttribute('data-conflicts'); 3463 if (!conflictsRaw) return; 3464 3465 let conflicts; 3466 try { 3467 conflicts = JSON.parse(decodeURIComponent(escape(atob(conflictsRaw)))); 3468 } catch (e) { 3469 // Fallback: try parsing as plain JSON (for PHP-rendered badges) 3470 try { 3471 conflicts = JSON.parse(conflictsRaw); 3472 } catch (e2) { 3473 console.error('Failed to parse conflicts:', e2); 3474 return; 3475 } 3476 } 3477 3478 // Get theme from the calendar container via CSS variables 3479 // Try closest ancestor first, then fall back to any calendar on the page 3480 let containerEl = badgeElement.closest('[id^="cal_"], [id^="panel_"], [id^="sidebar-widget-"], .calendar-compact-container, .event-panel-standalone'); 3481 if (!containerEl) { 3482 // Badge might be inside a day popup (appended to body) - find any calendar container 3483 containerEl = document.querySelector('.calendar-compact-container, .event-panel-standalone, [id^="sidebar-widget-"]'); 3484 } 3485 const cs = containerEl ? getComputedStyle(containerEl) : null; 3486 3487 const bg = cs ? cs.getPropertyValue('--background-site').trim() || '#242424' : '#242424'; 3488 const border = cs ? cs.getPropertyValue('--border-main').trim() || '#00cc07' : '#00cc07'; 3489 const textPrimary = cs ? cs.getPropertyValue('--text-primary').trim() || '#00cc07' : '#00cc07'; 3490 const textDim = cs ? cs.getPropertyValue('--text-dim').trim() || '#00aa00' : '#00aa00'; 3491 const shadow = cs ? cs.getPropertyValue('--shadow-color').trim() || 'rgba(0, 204, 7, 0.3)' : 'rgba(0, 204, 7, 0.3)'; 3492 3493 // Create tooltip 3494 const tooltip = document.createElement('div'); 3495 tooltip.id = 'conflict-tooltip'; 3496 tooltip.className = 'conflict-tooltip'; 3497 3498 // Apply theme styles 3499 tooltip.style.background = bg; 3500 tooltip.style.borderColor = border; 3501 tooltip.style.color = textPrimary; 3502 tooltip.style.boxShadow = '0 4px 12px ' + shadow; 3503 3504 // Build content with themed colors 3505 let html = '<div class="conflict-tooltip-header" style="background: ' + border + '; color: ' + bg + '; border-bottom: 1px solid ' + border + ';">⚠️ Time Conflicts</div>'; 3506 html += '<div class="conflict-tooltip-body">'; 3507 conflicts.forEach(conflict => { 3508 html += '<div class="conflict-item" style="color: ' + textDim + '; border-bottom-color: ' + border + ';">• ' + escapeHtml(conflict) + '</div>'; 3509 }); 3510 html += '</div>'; 3511 3512 tooltip.innerHTML = html; 3513 document.body.appendChild(tooltip); 3514 3515 // Position tooltip 3516 const rect = badgeElement.getBoundingClientRect(); 3517 const tooltipRect = tooltip.getBoundingClientRect(); 3518 3519 // Position above the badge, centered 3520 let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); 3521 let top = rect.top - tooltipRect.height - 8; 3522 3523 // Keep tooltip within viewport 3524 if (left < 10) left = 10; 3525 if (left + tooltipRect.width > window.innerWidth - 10) { 3526 left = window.innerWidth - tooltipRect.width - 10; 3527 } 3528 if (top < 10) { 3529 // If not enough room above, show below 3530 top = rect.bottom + 8; 3531 } 3532 3533 tooltip.style.left = left + 'px'; 3534 tooltip.style.top = top + 'px'; 3535 tooltip.style.opacity = '1'; 3536}; 3537 3538// Hide conflict tooltip 3539window.hideConflictTooltip = function() { 3540 const tooltip = document.getElementById('conflict-tooltip'); 3541 if (tooltip) { 3542 tooltip.remove(); 3543 } 3544}; 3545 3546// Fuzzy search helper for event filtering - normalizes text for matching 3547function eventSearchNormalize(text) { 3548 if (typeof text !== 'string') { 3549 console.log('[eventSearchNormalize] WARNING: text is not a string:', typeof text, text); 3550 return ''; 3551 } 3552 return text 3553 .toLowerCase() 3554 .trim() 3555 // Remove common punctuation that might differ 3556 .replace(/[''\u2018\u2019]/g, '') // Remove apostrophes/quotes 3557 .replace(/["""\u201C\u201D]/g, '') // Remove smart quotes 3558 .replace(/[-–—]/g, ' ') // Dashes to spaces 3559 .replace(/[.,!?;:]/g, '') // Remove punctuation 3560 .replace(/\s+/g, ' ') // Normalize whitespace 3561 .trim(); 3562} 3563 3564// Check if search term matches text for event filtering 3565function eventSearchMatch(text, searchTerm) { 3566 const normalizedText = eventSearchNormalize(text); 3567 const normalizedSearch = eventSearchNormalize(searchTerm); 3568 3569 // Direct match after normalization 3570 if (normalizedText.includes(normalizedSearch)) { 3571 return true; 3572 } 3573 3574 // Split search into words and check if all words are present 3575 const searchWords = normalizedSearch.split(' ').filter(w => w.length > 0); 3576 if (searchWords.length > 1) { 3577 return searchWords.every(word => normalizedText.includes(word)); 3578 } 3579 3580 return false; 3581} 3582 3583// Filter events by search term 3584window.filterEvents = function(calId, searchTerm) { 3585 const eventList = document.getElementById('eventlist-' + calId); 3586 const searchClear = document.getElementById('search-clear-' + calId); 3587 const searchMode = document.getElementById('search-mode-' + calId); 3588 3589 if (!eventList) return; 3590 3591 // Check if we're in "all dates" mode 3592 const isAllDatesMode = searchMode && searchMode.classList.contains('all-dates'); 3593 3594 // Show/hide clear button 3595 if (searchClear) { 3596 searchClear.style.display = searchTerm ? 'block' : 'none'; 3597 } 3598 3599 searchTerm = searchTerm.trim(); 3600 3601 // If all-dates mode and we have a search term, do AJAX search 3602 if (isAllDatesMode && searchTerm.length >= 2) { 3603 searchAllDates(calId, searchTerm); 3604 return; 3605 } 3606 3607 // If all-dates mode but search cleared, restore normal view 3608 if (isAllDatesMode && !searchTerm) { 3609 // Remove search results container if exists 3610 const resultsContainer = eventList.querySelector('.all-dates-results'); 3611 if (resultsContainer) { 3612 resultsContainer.remove(); 3613 } 3614 // Show normal event items 3615 eventList.querySelectorAll('.event-compact-item').forEach(item => { 3616 item.style.display = ''; 3617 }); 3618 // Show past events toggle if it exists 3619 const pastToggle = eventList.querySelector('.past-events-toggle'); 3620 if (pastToggle) pastToggle.style.display = ''; 3621 } 3622 3623 // Get all event items 3624 const eventItems = eventList.querySelectorAll('.event-compact-item'); 3625 let visibleCount = 0; 3626 let hiddenPastCount = 0; 3627 3628 eventItems.forEach(item => { 3629 const title = item.querySelector('.event-title-compact'); 3630 const description = item.querySelector('.event-desc-compact'); 3631 const dateTime = item.querySelector('.event-date-time'); 3632 3633 // Build searchable text 3634 let searchableText = ''; 3635 if (title) searchableText += title.textContent + ' '; 3636 if (description) searchableText += description.textContent + ' '; 3637 if (dateTime) searchableText += dateTime.textContent + ' '; 3638 3639 // Check if matches search using fuzzy matching 3640 const matches = !searchTerm || eventSearchMatch(searchableText, searchTerm); 3641 3642 if (matches) { 3643 item.style.display = ''; 3644 visibleCount++; 3645 } else { 3646 item.style.display = 'none'; 3647 // Check if this is a past event 3648 if (item.classList.contains('event-past') || item.classList.contains('event-completed')) { 3649 hiddenPastCount++; 3650 } 3651 } 3652 }); 3653 3654 // Update past events toggle if it exists 3655 const pastToggle = eventList.querySelector('.past-events-toggle'); 3656 const pastLabel = eventList.querySelector('.past-events-label'); 3657 const pastContent = document.getElementById('past-events-' + calId); 3658 3659 if (pastToggle && pastLabel && pastContent) { 3660 const visiblePastEvents = pastContent.querySelectorAll('.event-compact-item:not([style*="display: none"])'); 3661 const totalPastVisible = visiblePastEvents.length; 3662 3663 if (totalPastVisible > 0) { 3664 pastLabel.textContent = `Past Events (${totalPastVisible})`; 3665 pastToggle.style.display = ''; 3666 } else { 3667 pastToggle.style.display = 'none'; 3668 } 3669 } 3670 3671 // Show "no results" message if nothing visible (only for month mode, not all-dates mode) 3672 let noResultsMsg = eventList.querySelector('.no-search-results'); 3673 if (visibleCount === 0 && searchTerm && !isAllDatesMode) { 3674 if (!noResultsMsg) { 3675 noResultsMsg = document.createElement('p'); 3676 noResultsMsg.className = 'no-search-results no-events-msg'; 3677 noResultsMsg.textContent = 'No events match your search'; 3678 eventList.appendChild(noResultsMsg); 3679 } 3680 noResultsMsg.style.display = 'block'; 3681 } else if (noResultsMsg) { 3682 noResultsMsg.style.display = 'none'; 3683 } 3684}; 3685 3686// Toggle search mode between "this month" and "all dates" 3687window.toggleSearchMode = function(calId, namespace) { 3688 const searchMode = document.getElementById('search-mode-' + calId); 3689 const searchInput = document.getElementById('event-search-' + calId); 3690 3691 if (!searchMode) return; 3692 3693 const isAllDates = searchMode.classList.toggle('all-dates'); 3694 3695 // Update button icon and title 3696 if (isAllDates) { 3697 searchMode.innerHTML = ''; 3698 searchMode.title = 'Searching all dates'; 3699 if (searchInput) { 3700 searchInput.placeholder = 'Search all dates...'; 3701 } 3702 } else { 3703 searchMode.innerHTML = ''; 3704 searchMode.title = 'Search this month only'; 3705 if (searchInput) { 3706 searchInput.placeholder = searchInput.classList.contains('panel-search-input') ? 'Search this month...' : ' Search...'; 3707 } 3708 } 3709 3710 // Re-run search with current term 3711 if (searchInput && searchInput.value) { 3712 filterEvents(calId, searchInput.value); 3713 } else { 3714 // Clear any all-dates results 3715 const eventList = document.getElementById('eventlist-' + calId); 3716 if (eventList) { 3717 const resultsContainer = eventList.querySelector('.all-dates-results'); 3718 if (resultsContainer) { 3719 resultsContainer.remove(); 3720 } 3721 // Show normal event items 3722 eventList.querySelectorAll('.event-compact-item').forEach(item => { 3723 item.style.display = ''; 3724 }); 3725 const pastToggle = eventList.querySelector('.past-events-toggle'); 3726 if (pastToggle) pastToggle.style.display = ''; 3727 } 3728 } 3729}; 3730 3731// Search all dates via AJAX 3732window.searchAllDates = function(calId, searchTerm) { 3733 const eventList = document.getElementById('eventlist-' + calId); 3734 if (!eventList) return; 3735 3736 // Get namespace from container 3737 const container = document.getElementById(calId); 3738 const namespace = container ? (container.dataset.namespace || '') : ''; 3739 const exclude = container ? (container.dataset.exclude || '') : ''; 3740 3741 // Hide normal event items 3742 eventList.querySelectorAll('.event-compact-item').forEach(item => { 3743 item.style.display = 'none'; 3744 }); 3745 const pastToggle = eventList.querySelector('.past-events-toggle'); 3746 if (pastToggle) pastToggle.style.display = 'none'; 3747 3748 // Remove old results container 3749 let resultsContainer = eventList.querySelector('.all-dates-results'); 3750 if (resultsContainer) { 3751 resultsContainer.remove(); 3752 } 3753 3754 // Create new results container 3755 resultsContainer = document.createElement('div'); 3756 resultsContainer.className = 'all-dates-results'; 3757 resultsContainer.innerHTML = '<p class="search-loading" style="text-align:center; padding:20px; color:var(--text-dim);"> Searching all dates...</p>'; 3758 eventList.appendChild(resultsContainer); 3759 3760 // Make AJAX request 3761 const params = new URLSearchParams({ 3762 call: 'plugin_calendar', 3763 action: 'search_all', 3764 search: searchTerm, 3765 namespace: namespace, 3766 exclude: exclude, 3767 _: new Date().getTime() 3768 }); 3769 3770 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 3771 method: 'POST', 3772 headers: { 3773 'Content-Type': 'application/x-www-form-urlencoded' 3774 }, 3775 body: params.toString() 3776 }) 3777 .then(r => r.json()) 3778 .then(data => { 3779 if (data.success && data.results) { 3780 if (data.results.length === 0) { 3781 resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim); font-style:italic;">No events found matching "' + escapeHtml(searchTerm) + '"</p>'; 3782 } else { 3783 let html = '<div class="all-dates-header" style="padding:4px 8px; background:var(--cell-today-bg, #e8f5e9); font-size:10px; font-weight:600; color:var(--text-bright, #00cc07); border-bottom:1px solid var(--border-color);">Found ' + data.results.length + ' event(s) across all dates</div>'; 3784 3785 data.results.forEach(event => { 3786 const dateObj = new Date(event.date + 'T00:00:00'); 3787 const dateDisplay = dateObj.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); 3788 const color = event.color || 'var(--text-bright, #00cc07)'; 3789 3790 html += '<div class="event-compact-item search-result-item" style="display:flex; border-bottom:1px solid var(--border-color, #e0e0e0); padding:6px 8px; gap:6px; cursor:pointer;" onclick="jumpToDate(\'' + calId + '\', \'' + event.date + '\', \'' + namespace + '\')">'; 3791 html += '<div style="width:3px; background:' + color + '; border-radius:1px; flex-shrink:0;"></div>'; 3792 html += '<div style="flex:1; min-width:0;">'; 3793 html += '<div class="event-title-compact" style="font-weight:600; color:var(--text-primary); font-size:11px;">' + escapeHtml(event.title) + '</div>'; 3794 html += '<div class="event-date-time" style="font-size:10px; color:var(--text-dim);">' + dateDisplay; 3795 if (event.time) { 3796 html += ' • ' + formatTimeRange(event.time, event.endTime); 3797 } 3798 html += '</div>'; 3799 if (event.namespace) { 3800 html += '<span style="font-size:9px; background:var(--text-bright); color:var(--background-site); padding:1px 4px; border-radius:2px; margin-top:2px; display:inline-block;">' + escapeHtml(event.namespace) + '</span>'; 3801 } 3802 html += '</div></div>'; 3803 }); 3804 3805 resultsContainer.innerHTML = html; 3806 } 3807 } else { 3808 resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim);">Search failed. Please try again.</p>'; 3809 } 3810 }) 3811 .catch(err => { 3812 console.error('Search error:', err); 3813 resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim);">Search failed. Please try again.</p>'; 3814 }); 3815}; 3816 3817// Jump to a specific date (used by search results) 3818window.jumpToDate = function(calId, date, namespace) { 3819 const parts = date.split('-'); 3820 const year = parseInt(parts[0]); 3821 const month = parseInt(parts[1]); 3822 3823 // Get container to check current month 3824 const container = document.getElementById(calId); 3825 const currentYear = container ? parseInt(container.dataset.year) : year; 3826 const currentMonth = container ? parseInt(container.dataset.month) : month; 3827 3828 // Get search elements 3829 const searchInput = document.getElementById('event-search-' + calId); 3830 const searchMode = document.getElementById('search-mode-' + calId); 3831 const searchClear = document.getElementById('search-clear-' + calId); 3832 const eventList = document.getElementById('eventlist-' + calId); 3833 3834 // Remove the all-dates results container 3835 if (eventList) { 3836 const resultsContainer = eventList.querySelector('.all-dates-results'); 3837 if (resultsContainer) { 3838 resultsContainer.remove(); 3839 } 3840 // Show normal event items again 3841 eventList.querySelectorAll('.event-compact-item').forEach(item => { 3842 item.style.display = ''; 3843 }); 3844 const pastToggle = eventList.querySelector('.past-events-toggle'); 3845 if (pastToggle) pastToggle.style.display = ''; 3846 3847 // Hide any no-results message 3848 const noResults = eventList.querySelector('.no-search-results'); 3849 if (noResults) noResults.style.display = 'none'; 3850 } 3851 3852 // Clear search input 3853 if (searchInput) { 3854 searchInput.value = ''; 3855 } 3856 3857 // Hide clear button 3858 if (searchClear) { 3859 searchClear.style.display = 'none'; 3860 } 3861 3862 // Switch back to month mode 3863 if (searchMode && searchMode.classList.contains('all-dates')) { 3864 searchMode.classList.remove('all-dates'); 3865 searchMode.innerHTML = ''; 3866 searchMode.title = 'Search this month only'; 3867 if (searchInput) { 3868 searchInput.placeholder = searchInput.classList.contains('panel-search-input') ? 'Search this month...' : ' Search...'; 3869 } 3870 } 3871 3872 // Check if we need to navigate to a different month 3873 if (year !== currentYear || month !== currentMonth) { 3874 // Navigate to the target month, then show popup 3875 navCalendar(calId, year, month, namespace); 3876 3877 // After navigation completes, show the day popup 3878 setTimeout(() => { 3879 showDayPopup(calId, date, namespace); 3880 }, 400); 3881 } else { 3882 // Same month - just show the popup 3883 showDayPopup(calId, date, namespace); 3884 } 3885}; 3886 3887// Clear event search 3888window.clearEventSearch = function(calId) { 3889 const searchInput = document.getElementById('event-search-' + calId); 3890 if (searchInput) { 3891 searchInput.value = ''; 3892 filterEvents(calId, ''); 3893 searchInput.focus(); 3894 } 3895}; 3896 3897// ============================================ 3898// PINK THEME - GLOWING PARTICLE EFFECTS 3899// ============================================ 3900 3901// Create glowing pink particle effects for pink theme 3902(function() { 3903 let pinkThemeActive = false; 3904 let trailTimer = null; 3905 let pixelTimer = null; 3906 3907 // Check if pink theme is active 3908 function checkPinkTheme() { 3909 const pinkCalendars = document.querySelectorAll('.calendar-theme-pink'); 3910 pinkThemeActive = pinkCalendars.length > 0; 3911 return pinkThemeActive; 3912 } 3913 3914 // Create trail particle 3915 function createTrailParticle(clientX, clientY) { 3916 if (!pinkThemeActive) return; 3917 3918 const trail = document.createElement('div'); 3919 trail.className = 'pink-cursor-trail'; 3920 trail.style.left = clientX + 'px'; 3921 trail.style.top = clientY + 'px'; 3922 trail.style.animation = 'cursor-trail-fade 0.5s ease-out forwards'; 3923 3924 document.body.appendChild(trail); 3925 3926 setTimeout(function() { 3927 trail.remove(); 3928 }, 500); 3929 } 3930 3931 // Create pixel sparkles 3932 function createPixelSparkles(clientX, clientY) { 3933 if (!pinkThemeActive || pixelTimer) return; 3934 3935 const pixelCount = 3 + Math.floor(Math.random() * 4); // 3-6 pixels 3936 3937 for (let i = 0; i < pixelCount; i++) { 3938 const pixel = document.createElement('div'); 3939 pixel.className = 'pink-pixel-sparkle'; 3940 3941 // Random offset from cursor 3942 const offsetX = (Math.random() - 0.5) * 30; 3943 const offsetY = (Math.random() - 0.5) * 30; 3944 3945 pixel.style.left = (clientX + offsetX) + 'px'; 3946 pixel.style.top = (clientY + offsetY) + 'px'; 3947 3948 // Random color - bright neon pinks and whites 3949 const colors = ['#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1']; 3950 const color = colors[Math.floor(Math.random() * colors.length)]; 3951 pixel.style.background = color; 3952 pixel.style.boxShadow = '0 0 2px ' + color + ', 0 0 4px ' + color + ', 0 0 6px #fff'; 3953 3954 // Random animation 3955 if (Math.random() > 0.5) { 3956 pixel.style.animation = 'pixel-twinkle 0.6s ease-out forwards'; 3957 } else { 3958 pixel.style.animation = 'pixel-float-away 0.8s ease-out forwards'; 3959 } 3960 3961 document.body.appendChild(pixel); 3962 3963 setTimeout(function() { 3964 pixel.remove(); 3965 }, 800); 3966 } 3967 3968 pixelTimer = setTimeout(function() { 3969 pixelTimer = null; 3970 }, 40); 3971 } 3972 3973 // Create explosion 3974 function createExplosion(clientX, clientY) { 3975 if (!pinkThemeActive) return; 3976 3977 const particleCount = 25; 3978 const colors = ['#ff1493', '#ff69b4', '#ff85c1', '#ffc0cb', '#fff']; 3979 3980 // Add hearts to explosion (8-12 hearts) 3981 const heartCount = 8 + Math.floor(Math.random() * 5); 3982 for (let i = 0; i < heartCount; i++) { 3983 const heart = document.createElement('div'); 3984 heart.textContent = ''; 3985 heart.style.position = 'fixed'; 3986 heart.style.left = clientX + 'px'; 3987 heart.style.top = clientY + 'px'; 3988 heart.style.pointerEvents = 'none'; 3989 heart.style.zIndex = '9999999'; 3990 heart.style.fontSize = (12 + Math.random() * 16) + 'px'; 3991 3992 // Random direction 3993 const angle = Math.random() * Math.PI * 2; 3994 const velocity = 60 + Math.random() * 80; 3995 const tx = Math.cos(angle) * velocity; 3996 const ty = Math.sin(angle) * velocity; 3997 3998 heart.style.setProperty('--tx', tx + 'px'); 3999 heart.style.setProperty('--ty', ty + 'px'); 4000 4001 const duration = 0.8 + Math.random() * 0.4; 4002 heart.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 4003 4004 document.body.appendChild(heart); 4005 4006 setTimeout(function() { 4007 heart.remove(); 4008 }, duration * 1000); 4009 } 4010 4011 // Main explosion particles 4012 for (let i = 0; i < particleCount; i++) { 4013 const particle = document.createElement('div'); 4014 particle.className = 'pink-particle'; 4015 4016 const color = colors[Math.floor(Math.random() * colors.length)]; 4017 particle.style.background = 'radial-gradient(circle, ' + color + ', transparent)'; 4018 particle.style.boxShadow = '0 0 10px ' + color + ', 0 0 20px ' + color; 4019 4020 particle.style.left = clientX + 'px'; 4021 particle.style.top = clientY + 'px'; 4022 4023 const angle = (Math.PI * 2 * i) / particleCount; 4024 const velocity = 50 + Math.random() * 100; 4025 const tx = Math.cos(angle) * velocity; 4026 const ty = Math.sin(angle) * velocity; 4027 4028 particle.style.setProperty('--tx', tx + 'px'); 4029 particle.style.setProperty('--ty', ty + 'px'); 4030 4031 const size = 4 + Math.random() * 6; 4032 particle.style.width = size + 'px'; 4033 particle.style.height = size + 'px'; 4034 4035 const duration = 0.6 + Math.random() * 0.4; 4036 particle.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 4037 4038 document.body.appendChild(particle); 4039 4040 setTimeout(function() { 4041 particle.remove(); 4042 }, duration * 1000); 4043 } 4044 4045 // Pixel sparkles 4046 const pixelSparkleCount = 40; 4047 4048 for (let i = 0; i < pixelSparkleCount; i++) { 4049 const pixel = document.createElement('div'); 4050 pixel.className = 'pink-pixel-sparkle'; 4051 4052 const pixelColors = ['#fff', '#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1']; 4053 const pixelColor = pixelColors[Math.floor(Math.random() * pixelColors.length)]; 4054 pixel.style.background = pixelColor; 4055 pixel.style.boxShadow = '0 0 3px ' + pixelColor + ', 0 0 6px ' + pixelColor + ', 0 0 9px #fff'; 4056 4057 const angle = Math.random() * Math.PI * 2; 4058 const distance = 30 + Math.random() * 80; 4059 const offsetX = Math.cos(angle) * distance; 4060 const offsetY = Math.sin(angle) * distance; 4061 4062 pixel.style.left = clientX + 'px'; 4063 pixel.style.top = clientY + 'px'; 4064 pixel.style.setProperty('--tx', offsetX + 'px'); 4065 pixel.style.setProperty('--ty', offsetY + 'px'); 4066 4067 const pixelSize = 1 + Math.random() * 2; 4068 pixel.style.width = pixelSize + 'px'; 4069 pixel.style.height = pixelSize + 'px'; 4070 4071 const duration = 0.4 + Math.random() * 0.4; 4072 if (Math.random() > 0.5) { 4073 pixel.style.animation = 'pixel-twinkle ' + duration + 's ease-out forwards'; 4074 } else { 4075 pixel.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 4076 } 4077 4078 document.body.appendChild(pixel); 4079 4080 setTimeout(function() { 4081 pixel.remove(); 4082 }, duration * 1000); 4083 } 4084 4085 // Flash 4086 const flash = document.createElement('div'); 4087 flash.style.position = 'fixed'; 4088 flash.style.left = clientX + 'px'; 4089 flash.style.top = clientY + 'px'; 4090 flash.style.width = '40px'; 4091 flash.style.height = '40px'; 4092 flash.style.borderRadius = '50%'; 4093 flash.style.background = 'radial-gradient(circle, rgba(255, 255, 255, 0.9), rgba(255, 20, 147, 0.6), transparent)'; 4094 flash.style.boxShadow = '0 0 40px #fff, 0 0 60px #ff1493, 0 0 80px #ff69b4'; 4095 flash.style.pointerEvents = 'none'; 4096 flash.style.zIndex = '9999999'; // Above everything including dialogs 4097 flash.style.transform = 'translate(-50%, -50%)'; 4098 flash.style.animation = 'cursor-trail-fade 0.3s ease-out forwards'; 4099 4100 document.body.appendChild(flash); 4101 4102 setTimeout(function() { 4103 flash.remove(); 4104 }, 300); 4105 } 4106 4107 function initPinkParticles() { 4108 if (!checkPinkTheme()) return; 4109 4110 // Use capture phase to catch events before stopPropagation 4111 document.addEventListener('mousemove', function(e) { 4112 if (!pinkThemeActive) return; 4113 4114 createTrailParticle(e.clientX, e.clientY); 4115 createPixelSparkles(e.clientX, e.clientY); 4116 }, true); // Capture phase! 4117 4118 // Throttle main trail 4119 document.addEventListener('mousemove', function(e) { 4120 if (!pinkThemeActive || trailTimer) return; 4121 4122 trailTimer = setTimeout(function() { 4123 trailTimer = null; 4124 }, 30); 4125 }, true); // Capture phase! 4126 4127 // Click explosion - use capture phase 4128 document.addEventListener('click', function(e) { 4129 if (!pinkThemeActive) return; 4130 4131 createExplosion(e.clientX, e.clientY); 4132 }, true); // Capture phase! 4133 } 4134 4135 // Initialize on load 4136 if (document.readyState === 'loading') { 4137 document.addEventListener('DOMContentLoaded', initPinkParticles); 4138 } else { 4139 initPinkParticles(); 4140 } 4141 4142 // Re-check theme if calendar is dynamically added 4143 // Must wait for document.body to exist 4144 function setupMutationObserver() { 4145 if (typeof MutationObserver !== 'undefined' && document.body) { 4146 const observer = new MutationObserver(function(mutations) { 4147 mutations.forEach(function(mutation) { 4148 if (mutation.addedNodes.length > 0) { 4149 mutation.addedNodes.forEach(function(node) { 4150 if (node.nodeType === 1 && node.classList && node.classList.contains('calendar-theme-pink')) { 4151 checkPinkTheme(); 4152 initPinkParticles(); 4153 } 4154 }); 4155 } 4156 }); 4157 }); 4158 4159 observer.observe(document.body, { 4160 childList: true, 4161 subtree: true 4162 }); 4163 } 4164 } 4165 4166 // Setup observer when DOM is ready 4167 if (document.readyState === 'loading') { 4168 document.addEventListener('DOMContentLoaded', setupMutationObserver); 4169 } else { 4170 setupMutationObserver(); 4171 } 4172})(); 4173 4174// Mobile touch event delegation for edit/delete buttons 4175// This ensures buttons work on mobile where onclick may not fire reliably 4176(function() { 4177 function handleButtonTouch(e) { 4178 const btn = e.target.closest('.event-edit-btn, .event-delete-btn, .event-action-btn'); 4179 if (!btn) return; 4180 4181 // Prevent double-firing with onclick 4182 e.preventDefault(); 4183 4184 // Small delay to show visual feedback 4185 setTimeout(function() { 4186 btn.click(); 4187 }, 10); 4188 } 4189 4190 // Use touchend for more reliable mobile handling 4191 document.addEventListener('touchend', handleButtonTouch, { passive: false }); 4192})(); 4193 4194// Static calendar navigation 4195window.navStaticCalendar = function(calId, direction) { 4196 const container = document.getElementById(calId); 4197 if (!container) return; 4198 4199 let year = parseInt(container.dataset.year); 4200 let month = parseInt(container.dataset.month); 4201 const namespace = container.dataset.namespace || ''; 4202 4203 // Calculate new month 4204 month += direction; 4205 if (month < 1) { 4206 month = 12; 4207 year--; 4208 } else if (month > 12) { 4209 month = 1; 4210 year++; 4211 } 4212 4213 // Fetch new calendar content via AJAX 4214 const params = new URLSearchParams({ 4215 call: 'plugin_calendar', 4216 action: 'get_static_calendar', 4217 year: year, 4218 month: month, 4219 namespace: namespace 4220 }); 4221 4222 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 4223 method: 'POST', 4224 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 4225 body: params.toString() 4226 }) 4227 .then(r => r.json()) 4228 .then(data => { 4229 if (data.success && data.html) { 4230 // Replace the container content 4231 container.outerHTML = data.html; 4232 } 4233 }) 4234 .catch(err => console.error('Static calendar navigation error:', err)); 4235}; 4236 4237// Print static calendar - opens print dialog with only calendar content 4238window.printStaticCalendar = function(calId) { 4239 const container = document.getElementById(calId); 4240 if (!container) return; 4241 4242 // Get the print view content 4243 const printView = container.querySelector('.static-print-view'); 4244 if (!printView) return; 4245 4246 // Create a new window for printing 4247 const printWindow = window.open('', '_blank', 'width=800,height=600'); 4248 4249 // Build print document with inline margins for maximum compatibility 4250 const printContent = ` 4251<!DOCTYPE html> 4252<html> 4253<head> 4254 <title>Calendar - ${container.dataset.year}-${String(container.dataset.month).padStart(2, '0')}</title> 4255 <style> 4256 * { margin: 0; padding: 0; box-sizing: border-box; } 4257 body { font-family: Arial, sans-serif; color: #333; background: white; } 4258 table { border-collapse: collapse; font-size: 12px; } 4259 th { background: #2c3e50; color: white; padding: 8px; text-align: left; -webkit-print-color-adjust: exact; print-color-adjust: exact; } 4260 td { padding: 6px 8px; border-bottom: 1px solid #ccc; vertical-align: top; } 4261 tr:nth-child(even) { background: #f0f0f0; -webkit-print-color-adjust: exact; print-color-adjust: exact; } 4262 .static-itinerary-important { background: #fffde7 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } 4263 .static-itinerary-date { font-weight: bold; white-space: nowrap; } 4264 .static-itinerary-time { white-space: nowrap; color: #555; } 4265 .static-itinerary-title { font-weight: 500; } 4266 .static-itinerary-desc { color: #555; font-size: 11px; } 4267 thead { display: table-header-group; } 4268 tr { page-break-inside: avoid; } 4269 h2 { font-size: 16px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 2px solid #333; } 4270 p { font-size: 12px; color: #666; margin-bottom: 15px; } 4271 </style> 4272</head> 4273<body style="margin: 0; padding: 0;"> 4274 <div style="padding: 50px 60px; margin: 0 auto; max-width: 800px;"> 4275 ${printView.innerHTML} 4276 </div> 4277 <script> 4278 window.onload = function() { 4279 setTimeout(function() { 4280 window.print(); 4281 }, 300); 4282 window.onafterprint = function() { 4283 window.close(); 4284 }; 4285 }; 4286 </script> 4287</body> 4288</html>`; 4289 4290 printWindow.document.write(printContent); 4291 printWindow.document.close(); 4292}; 4293 4294// ============================================================================ 4295// ACCESSIBILITY - Screen reader announcements 4296// ============================================================================ 4297 4298// Create ARIA live region for announcements 4299(function() { 4300 function createAriaLive() { 4301 if (document.getElementById('calendar-aria-live')) return; 4302 if (!document.body) { 4303 // Body not ready yet (script loaded in <head>), wait for it 4304 document.addEventListener('DOMContentLoaded', createAriaLive); 4305 return; 4306 } 4307 var ariaLive = document.createElement('div'); 4308 ariaLive.id = 'calendar-aria-live'; 4309 ariaLive.setAttribute('role', 'status'); 4310 ariaLive.setAttribute('aria-live', 'polite'); 4311 ariaLive.setAttribute('aria-atomic', 'true'); 4312 ariaLive.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;'; 4313 document.body.appendChild(ariaLive); 4314 } 4315 createAriaLive(); 4316})(); 4317 4318// Announce message to screen readers 4319window.announceToScreenReader = function(message) { 4320 var ariaLive = document.getElementById('calendar-aria-live'); 4321 if (ariaLive) { 4322 ariaLive.textContent = ''; 4323 // Small delay to ensure screen reader picks up the change 4324 setTimeout(function() { 4325 ariaLive.textContent = message; 4326 }, 100); 4327 } 4328}; 4329 4330// End of calendar plugin JavaScript 4331