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