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