1/** 2 * DokuWiki Compact Calendar Plugin JavaScript 3 * Loaded independently to avoid DokuWiki concatenation issues 4 */ 5 6// Ensure DOKU_BASE is defined - check multiple sources 7if (typeof DOKU_BASE === 'undefined') { 8 // Try to get from global jsinfo object (DokuWiki standard) 9 if (typeof window.jsinfo !== 'undefined' && window.jsinfo.dokubase) { 10 window.DOKU_BASE = window.jsinfo.dokubase; 11 } else { 12 // Fallback: extract from script source path 13 var scripts = document.getElementsByTagName('script'); 14 var pluginScriptPath = null; 15 for (var i = 0; i < scripts.length; i++) { 16 if (scripts[i].src && scripts[i].src.indexOf('calendar/script.js') !== -1) { 17 pluginScriptPath = scripts[i].src; 18 break; 19 } 20 } 21 22 if (pluginScriptPath) { 23 // Extract base path from: .../lib/plugins/calendar/script.js 24 var match = pluginScriptPath.match(/^(.*?)lib\/plugins\//); 25 window.DOKU_BASE = match ? match[1] : '/'; 26 } else { 27 // Last resort: use root 28 window.DOKU_BASE = '/'; 29 } 30 } 31} 32 33// Shorthand for convenience 34var DOKU_BASE = window.DOKU_BASE || '/'; 35 36// Helper: propagate CSS variables from a calendar container to a target element 37// This is needed for dialogs/popups that use position:fixed (they inherit CSS vars 38// from DOM parents per spec, but some DokuWiki templates break this inheritance) 39function propagateThemeVars(calId, targetEl) { 40 if (!targetEl) return; 41 // Find the calendar container (could be cal_, panel_, sidebar-widget-, etc.) 42 const container = document.getElementById(calId) 43 || document.getElementById('sidebar-widget-' + calId) 44 || document.querySelector('[id$="' + calId + '"]'); 45 if (!container) return; 46 const cs = getComputedStyle(container); 47 const vars = [ 48 '--background-site', '--background-alt', '--background-header', 49 '--text-primary', '--text-bright', '--text-dim', 50 '--border-color', '--border-main', 51 '--cell-bg', '--cell-today-bg', '--grid-bg', 52 '--shadow-color', '--header-border', '--header-shadow', 53 '--btn-text' 54 ]; 55 vars.forEach(v => { 56 const val = cs.getPropertyValue(v).trim(); 57 if (val) targetEl.style.setProperty(v, val); 58 }); 59} 60 61// Filter calendar by namespace 62window.filterCalendarByNamespace = function(calId, namespace) { 63 // Get current year and month from calendar 64 const container = document.getElementById(calId); 65 if (!container) { 66 console.error('Calendar container not found:', calId); 67 return; 68 } 69 70 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 71 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 72 73 // Reload calendar with the filtered namespace 74 navCalendar(calId, year, month, namespace); 75}; 76 77// Navigate to different month 78window.navCalendar = function(calId, year, month, namespace) { 79 80 const params = new URLSearchParams({ 81 call: 'plugin_calendar', 82 action: 'load_month', 83 year: year, 84 month: month, 85 namespace: namespace, 86 _: new Date().getTime() // Cache buster 87 }); 88 89 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 90 method: 'POST', 91 headers: { 92 'Content-Type': 'application/x-www-form-urlencoded', 93 'Cache-Control': 'no-cache, no-store, must-revalidate', 94 'Pragma': 'no-cache' 95 }, 96 body: params.toString() 97 }) 98 .then(r => r.json()) 99 .then(data => { 100 if (data.success) { 101 rebuildCalendar(calId, data.year, data.month, data.events, namespace); 102 } else { 103 console.error('Failed to load month:', data.error); 104 } 105 }) 106 .catch(err => { 107 console.error('Error loading month:', err); 108 }); 109}; 110 111// Jump to current month 112window.jumpToToday = function(calId, namespace) { 113 const today = new Date(); 114 const year = today.getFullYear(); 115 const month = today.getMonth() + 1; // JavaScript months are 0-indexed 116 navCalendar(calId, year, month, namespace); 117}; 118 119// Jump to today for event panel 120window.jumpTodayPanel = function(calId, namespace) { 121 const today = new Date(); 122 const year = today.getFullYear(); 123 const month = today.getMonth() + 1; 124 navEventPanel(calId, year, month, namespace); 125}; 126 127// Open month picker dialog 128window.openMonthPicker = function(calId, currentYear, currentMonth, namespace) { 129 130 const overlay = document.getElementById('month-picker-overlay-' + calId); 131 132 const monthSelect = document.getElementById('month-picker-month-' + calId); 133 134 const yearSelect = document.getElementById('month-picker-year-' + calId); 135 136 if (!overlay) { 137 console.error('Month picker overlay not found! ID:', 'month-picker-overlay-' + calId); 138 return; 139 } 140 141 if (!monthSelect || !yearSelect) { 142 console.error('Select elements not found!'); 143 return; 144 } 145 146 // Set current values 147 monthSelect.value = currentMonth; 148 yearSelect.value = currentYear; 149 150 // Show overlay 151 overlay.style.display = 'flex'; 152}; 153 154// Open month picker dialog for event panel 155window.openMonthPickerPanel = function(calId, currentYear, currentMonth, namespace) { 156 openMonthPicker(calId, currentYear, currentMonth, namespace); 157}; 158 159// Close month picker dialog 160window.closeMonthPicker = function(calId) { 161 const overlay = document.getElementById('month-picker-overlay-' + calId); 162 overlay.style.display = 'none'; 163}; 164 165// Jump to selected month 166window.jumpToSelectedMonth = function(calId, namespace) { 167 const monthSelect = document.getElementById('month-picker-month-' + calId); 168 const yearSelect = document.getElementById('month-picker-year-' + calId); 169 170 const month = parseInt(monthSelect.value); 171 const year = parseInt(yearSelect.value); 172 173 closeMonthPicker(calId); 174 175 // Check if this is a calendar or event panel 176 const container = document.getElementById(calId); 177 if (container && container.classList.contains('event-panel-standalone')) { 178 navEventPanel(calId, year, month, namespace); 179 } else { 180 navCalendar(calId, year, month, namespace); 181 } 182}; 183 184// Rebuild calendar grid after navigation 185window.rebuildCalendar = function(calId, year, month, events, namespace) { 186 187 const container = document.getElementById(calId); 188 const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 189 'July', 'August', 'September', 'October', 'November', 'December']; 190 191 // Get theme data from container 192 const theme = container.dataset.theme || 'matrix'; 193 let themeStyles = {}; 194 try { 195 themeStyles = JSON.parse(container.dataset.themeStyles || '{}'); 196 } catch (e) { 197 console.error('Failed to parse theme styles:', e); 198 themeStyles = {}; 199 } 200 201 // Preserve original namespace if not yet set 202 if (!container.dataset.originalNamespace) { 203 container.setAttribute('data-original-namespace', namespace || ''); 204 } 205 206 // Update container data attributes for current month/year 207 container.setAttribute('data-year', year); 208 container.setAttribute('data-month', month); 209 210 // Update embedded events data 211 let eventsDataEl = document.getElementById('events-data-' + calId); 212 if (eventsDataEl) { 213 eventsDataEl.textContent = JSON.stringify(events); 214 } else { 215 eventsDataEl = document.createElement('script'); 216 eventsDataEl.type = 'application/json'; 217 eventsDataEl.id = 'events-data-' + calId; 218 eventsDataEl.textContent = JSON.stringify(events); 219 container.appendChild(eventsDataEl); 220 } 221 222 // Update header 223 const header = container.querySelector('.calendar-compact-header h3'); 224 header.textContent = monthNames[month - 1] + ' ' + year; 225 226 // Update or create namespace filter indicator 227 let filterIndicator = container.querySelector('.calendar-namespace-filter'); 228 const shouldShowFilter = namespace && namespace !== '' && namespace !== '*' && 229 namespace.indexOf('*') === -1 && namespace.indexOf(';') === -1; 230 231 if (shouldShowFilter) { 232 // Show/update filter indicator 233 if (!filterIndicator) { 234 // Create filter indicator if it doesn't exist 235 const headerDiv = container.querySelector('.calendar-compact-header'); 236 if (headerDiv) { 237 filterIndicator = document.createElement('div'); 238 filterIndicator.className = 'calendar-namespace-filter'; 239 filterIndicator.id = 'namespace-filter-' + calId; 240 headerDiv.parentNode.insertBefore(filterIndicator, headerDiv.nextSibling); 241 } 242 } 243 244 if (filterIndicator) { 245 filterIndicator.innerHTML = 246 '<span class="namespace-filter-label">Filtering:</span>' + 247 '<span class="namespace-filter-name">' + escapeHtml(namespace) + '</span>' + 248 '<button class="namespace-filter-clear" onclick="clearNamespaceFilter(\'' + calId + '\')" title="Clear filter and show all namespaces">✕</button>'; 249 filterIndicator.style.display = 'flex'; 250 } 251 } else { 252 // Hide filter indicator 253 if (filterIndicator) { 254 filterIndicator.style.display = 'none'; 255 } 256 } 257 258 // Update container's namespace attribute 259 container.setAttribute('data-namespace', namespace || ''); 260 261 // Update nav buttons 262 let prevMonth = month - 1; 263 let prevYear = year; 264 if (prevMonth < 1) { 265 prevMonth = 12; 266 prevYear--; 267 } 268 269 let nextMonth = month + 1; 270 let nextYear = year; 271 if (nextMonth > 12) { 272 nextMonth = 1; 273 nextYear++; 274 } 275 276 const navBtns = container.querySelectorAll('.cal-nav-btn'); 277 navBtns[0].setAttribute('onclick', `navCalendar('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 278 navBtns[1].setAttribute('onclick', `navCalendar('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 279 280 // Rebuild calendar grid 281 const tbody = container.querySelector('.calendar-compact-grid tbody'); 282 const firstDay = new Date(year, month - 1, 1); 283 const daysInMonth = new Date(year, month, 0).getDate(); 284 const dayOfWeek = firstDay.getDay(); 285 286 // Calculate month boundaries 287 const monthStart = new Date(year, month - 1, 1); 288 const monthEnd = new Date(year, month - 1, daysInMonth); 289 290 // Build a map of all events with their date ranges 291 const eventRanges = {}; 292 for (const [dateKey, dayEvents] of Object.entries(events)) { 293 // Defensive check: ensure dayEvents is an array 294 if (!Array.isArray(dayEvents)) { 295 console.error('dayEvents is not an array for dateKey:', dateKey, 'value:', dayEvents); 296 continue; 297 } 298 299 // Only process events that could possibly overlap with this month/year 300 const dateYear = parseInt(dateKey.split('-')[0]); 301 302 // Skip events from completely different years (unless they're very long multi-day events) 303 if (Math.abs(dateYear - year) > 1) { 304 continue; 305 } 306 307 for (const evt of dayEvents) { 308 const startDate = dateKey; 309 const endDate = evt.endDate || dateKey; 310 311 // Check if event overlaps with current month 312 const eventStart = new Date(startDate + 'T00:00:00'); 313 const eventEnd = new Date(endDate + 'T00:00:00'); 314 315 // Skip if event doesn't overlap with current month 316 if (eventEnd < monthStart || eventStart > monthEnd) { 317 continue; 318 } 319 320 // Create entry for each day the event spans 321 const start = new Date(startDate + 'T00:00:00'); 322 const end = new Date(endDate + 'T00:00:00'); 323 const current = new Date(start); 324 325 while (current <= end) { 326 const currentKey = current.toISOString().split('T')[0]; 327 328 // Check if this date is in current month 329 const currentDate = new Date(currentKey + 'T00:00:00'); 330 if (currentDate.getFullYear() === year && currentDate.getMonth() === month - 1) { 331 if (!eventRanges[currentKey]) { 332 eventRanges[currentKey] = []; 333 } 334 335 // Add event with span information 336 const eventCopy = {...evt}; 337 eventCopy._span_start = startDate; 338 eventCopy._span_end = endDate; 339 eventCopy._is_first_day = (currentKey === startDate); 340 eventCopy._is_last_day = (currentKey === endDate); 341 eventCopy._original_date = dateKey; 342 343 // Check if event continues from previous month or to next month 344 eventCopy._continues_from_prev = (eventStart < monthStart); 345 eventCopy._continues_to_next = (eventEnd > monthEnd); 346 347 eventRanges[currentKey].push(eventCopy); 348 } 349 350 current.setDate(current.getDate() + 1); 351 } 352 } 353 } 354 355 let html = ''; 356 let currentDay = 1; 357 const rowCount = Math.ceil((daysInMonth + dayOfWeek) / 7); 358 359 for (let row = 0; row < rowCount; row++) { 360 html += '<tr>'; 361 for (let col = 0; col < 7; col++) { 362 if ((row === 0 && col < dayOfWeek) || currentDay > daysInMonth) { 363 html += `<td class="cal-empty"></td>`; 364 } else { 365 const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(currentDay).padStart(2, '0')}`; 366 367 // Get today's date in local timezone 368 const todayObj = new Date(); 369 const today = `${todayObj.getFullYear()}-${String(todayObj.getMonth() + 1).padStart(2, '0')}-${String(todayObj.getDate()).padStart(2, '0')}`; 370 371 const isToday = dateKey === today; 372 const hasEvents = eventRanges[dateKey] && eventRanges[dateKey].length > 0; 373 374 let classes = 'cal-day'; 375 if (isToday) classes += ' cal-today'; 376 if (hasEvents) classes += ' cal-has-events'; 377 378 const dayNumClass = isToday ? 'day-num day-num-today' : 'day-num'; 379 380 html += `<td class="${classes}" data-date="${dateKey}" onclick="showDayPopup('${calId}', '${dateKey}', '${namespace}')">`; 381 html += `<span class="${dayNumClass}">${currentDay}</span>`; 382 383 if (hasEvents) { 384 // Sort events by time (no time first, then by time) 385 const sortedEvents = [...eventRanges[dateKey]].sort((a, b) => { 386 const timeA = a.time || ''; 387 const timeB = b.time || ''; 388 if (!timeA && timeB) return -1; 389 if (timeA && !timeB) return 1; 390 if (!timeA && !timeB) return 0; 391 return timeA.localeCompare(timeB); 392 }); 393 394 // Get important namespaces 395 let importantNamespaces = ['important']; 396 if (container.dataset.importantNamespaces) { 397 try { 398 importantNamespaces = JSON.parse(container.dataset.importantNamespaces); 399 } catch (e) {} 400 } 401 402 // Show colored stacked bars for each event 403 html += '<div class="event-indicators">'; 404 for (const evt of sortedEvents) { 405 const eventId = evt.id || ''; 406 const eventColor = evt.color || '#3498db'; 407 const eventTitle = evt.title || 'Event'; 408 const eventTime = evt.time || ''; 409 const originalDate = evt._original_date || dateKey; 410 const isFirstDay = evt._is_first_day !== undefined ? evt._is_first_day : true; 411 const isLastDay = evt._is_last_day !== undefined ? evt._is_last_day : true; 412 413 // Check if important namespace 414 let evtNs = evt.namespace || evt._namespace || ''; 415 let isImportant = false; 416 for (const impNs of importantNamespaces) { 417 if (evtNs === impNs || evtNs.startsWith(impNs + ':')) { 418 isImportant = true; 419 break; 420 } 421 } 422 423 let barClass = !eventTime ? 'event-bar-no-time' : 'event-bar-timed'; 424 if (!isFirstDay) barClass += ' event-bar-continues'; 425 if (!isLastDay) barClass += ' event-bar-continuing'; 426 if (isImportant) { 427 barClass += ' event-bar-important'; 428 if (isFirstDay) { 429 barClass += ' event-bar-has-star'; 430 } 431 } 432 433 html += `<span class="event-bar ${barClass}" `; 434 html += `style="background: ${eventColor};" `; 435 html += `title="${isImportant ? '⭐ ' : ''}${escapeHtml(eventTitle)}${eventTime ? ' @ ' + eventTime : ''}" `; 436 html += `onclick="event.stopPropagation(); highlightEvent('${calId}', '${eventId}', '${originalDate}');">`; 437 html += '</span>'; 438 } 439 html += '</div>'; 440 } 441 442 html += '</td>'; 443 currentDay++; 444 } 445 } 446 html += '</tr>'; 447 } 448 449 tbody.innerHTML = html; 450 451 // Update Today button with current namespace 452 const todayBtn = container.querySelector('.cal-today-btn'); 453 if (todayBtn) { 454 todayBtn.setAttribute('onclick', `jumpToToday('${calId}', '${namespace}')`); 455 } 456 457 // Update month picker with current namespace 458 const monthPicker = container.querySelector('.calendar-month-picker'); 459 if (monthPicker) { 460 monthPicker.setAttribute('onclick', `openMonthPicker('${calId}', ${year}, ${month}, '${namespace}')`); 461 } 462 463 // Rebuild event list - server already filtered to current month 464 const eventList = container.querySelector('.event-list-compact'); 465 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 466 467 // Auto-scroll to first future event (past events will be above viewport) 468 setTimeout(() => { 469 const firstFuture = eventList.querySelector('[data-first-future="true"]'); 470 if (firstFuture) { 471 firstFuture.scrollIntoView({ behavior: 'smooth', block: 'start' }); 472 } 473 }, 100); 474 475 // Update title 476 const title = container.querySelector('#eventlist-title-' + calId); 477 title.textContent = 'Events'; 478}; 479 480// Render event list from data 481window.renderEventListFromData = function(events, calId, namespace, year, month) { 482 if (!events || Object.keys(events).length === 0) { 483 return '<p class="no-events-msg">No events this month</p>'; 484 } 485 486 // Get theme data from container 487 const container = document.getElementById(calId); 488 let themeStyles = {}; 489 if (container && container.dataset.themeStyles) { 490 try { 491 themeStyles = JSON.parse(container.dataset.themeStyles); 492 } catch (e) { 493 console.error('Failed to parse theme styles in renderEventListFromData:', e); 494 } 495 } 496 497 // Check for time conflicts 498 events = checkTimeConflicts(events, null); 499 500 let pastHtml = ''; 501 let futureHtml = ''; 502 let pastCount = 0; 503 504 const sortedDates = Object.keys(events).sort(); 505 const today = new Date(); 506 today.setHours(0, 0, 0, 0); 507 const todayStr = today.toISOString().split('T')[0]; 508 509 // Helper function to check if event is past (with 15-minute grace period) 510 const isEventPast = function(dateKey, time) { 511 // If event is on a past date, it's definitely past 512 if (dateKey < todayStr) { 513 return true; 514 } 515 516 // If event is on a future date, it's definitely not past 517 if (dateKey > todayStr) { 518 return false; 519 } 520 521 // Event is today - check time with grace period 522 if (time && time.trim() !== '') { 523 try { 524 const now = new Date(); 525 const eventDateTime = new Date(dateKey + 'T' + time); 526 527 // Add 15-minute grace period 528 const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000); 529 530 // Event is past if current time > event time + 15 minutes 531 return now > gracePeriodEnd; 532 } catch (e) { 533 // If time parsing fails, treat as future 534 return false; 535 } 536 } 537 538 // No time specified for today's event, treat as future 539 return false; 540 }; 541 542 // Filter events to only current month if year/month provided 543 const monthStart = year && month ? new Date(year, month - 1, 1) : null; 544 const monthEnd = year && month ? new Date(year, month, 0, 23, 59, 59) : null; 545 546 for (const dateKey of sortedDates) { 547 // Skip events not in current month if filtering 548 if (monthStart && monthEnd) { 549 const eventDate = new Date(dateKey + 'T00:00:00'); 550 551 if (eventDate < monthStart || eventDate > monthEnd) { 552 continue; 553 } 554 } 555 556 // Sort events within this day by time (all-day events at top) 557 const dayEvents = events[dateKey]; 558 dayEvents.sort((a, b) => { 559 const timeA = a.time && a.time.trim() !== '' ? a.time : null; 560 const timeB = b.time && b.time.trim() !== '' ? b.time : null; 561 562 // All-day events (no time) go to the TOP 563 if (timeA === null && timeB !== null) return -1; // A before B 564 if (timeA !== null && timeB === null) return 1; // A after B 565 if (timeA === null && timeB === null) return 0; // Both all-day, equal 566 567 // Both have times, sort chronologically 568 return timeA.localeCompare(timeB); 569 }); 570 571 for (const event of dayEvents) { 572 const isTask = event.isTask || false; 573 const completed = event.completed || false; 574 575 // Use helper function to determine if event is past (with grace period) 576 const isPast = isEventPast(dateKey, event.time); 577 const isPastDue = isPast && isTask && !completed; 578 579 // Determine if this goes in past section 580 const isPastOrCompleted = (isPast && (!isTask || completed)) || completed; 581 582 const eventHtml = renderEventItem(event, dateKey, calId, namespace); 583 584 if (isPastOrCompleted) { 585 pastCount++; 586 pastHtml += eventHtml; 587 } else { 588 futureHtml += eventHtml; 589 } 590 } 591 } 592 593 let html = ''; 594 595 // Add collapsible past events section if any exist 596 if (pastCount > 0) { 597 html += '<div class="past-events-section">'; 598 html += '<div class="past-events-toggle" onclick="togglePastEvents(\'' + calId + '\')">'; 599 html += '<span class="past-events-arrow" id="past-arrow-' + calId + '">▶</span> '; 600 html += '<span class="past-events-label">Past Events (' + pastCount + ')</span>'; 601 html += '</div>'; 602 html += '<div class="past-events-content" id="past-events-' + calId + '" style="display:none;">'; 603 html += pastHtml; 604 html += '</div>'; 605 html += '</div>'; 606 } else { 607 } 608 609 // Add future events 610 html += futureHtml; 611 612 613 if (!html) { 614 return '<p class="no-events-msg">No events this month</p>'; 615 } 616 617 return html; 618}; 619 620// Show day popup with events when clicking a date 621window.showDayPopup = function(calId, date, namespace) { 622 // Get events for this calendar 623 const eventsDataEl = document.getElementById('events-data-' + calId); 624 let events = {}; 625 626 if (eventsDataEl) { 627 try { 628 events = JSON.parse(eventsDataEl.textContent); 629 } catch (e) { 630 console.error('Failed to parse events data:', e); 631 } 632 } 633 634 const dayEvents = events[date] || []; 635 636 // Check for conflicts on this day 637 const dayEventsObj = {[date]: dayEvents}; 638 const checkedEvents = checkTimeConflicts(dayEventsObj, null); 639 const dayEventsWithConflicts = checkedEvents[date] || dayEvents; 640 641 // Sort events: all-day at top, then chronological by time 642 dayEventsWithConflicts.sort((a, b) => { 643 const timeA = a.time && a.time.trim() !== '' ? a.time : null; 644 const timeB = b.time && b.time.trim() !== '' ? b.time : null; 645 646 // All-day events (no time) go to the TOP 647 if (timeA === null && timeB !== null) return -1; // A before B 648 if (timeA !== null && timeB === null) return 1; // A after B 649 if (timeA === null && timeB === null) return 0; // Both all-day, equal 650 651 // Both have times, sort chronologically 652 return timeA.localeCompare(timeB); 653 }); 654 655 const dateObj = new Date(date + 'T00:00:00'); 656 const displayDate = dateObj.toLocaleDateString('en-US', { 657 weekday: 'long', 658 month: 'long', 659 day: 'numeric', 660 year: 'numeric' 661 }); 662 663 // Create popup 664 let popup = document.getElementById('day-popup-' + calId); 665 if (!popup) { 666 popup = document.createElement('div'); 667 popup.id = 'day-popup-' + calId; 668 popup.className = 'day-popup'; 669 document.body.appendChild(popup); 670 } 671 672 // Get theme styles 673 const container = document.getElementById(calId); 674 const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {}; 675 const theme = container ? container.dataset.theme : 'matrix'; 676 677 let html = '<div class="day-popup-overlay" onclick="closeDayPopup(\'' + calId + '\')"></div>'; 678 html += '<div class="day-popup-content">'; 679 html += '<div class="day-popup-header">'; 680 html += '<h4>' + displayDate + '</h4>'; 681 html += '<button class="popup-close" onclick="closeDayPopup(\'' + calId + '\')">×</button>'; 682 html += '</div>'; 683 684 html += '<div class="day-popup-body">'; 685 686 if (dayEventsWithConflicts.length === 0) { 687 html += '<p class="no-events-msg">No events on this day</p>'; 688 } else { 689 html += '<div class="popup-events-list">'; 690 dayEventsWithConflicts.forEach(event => { 691 const color = event.color || '#3498db'; 692 693 // Use individual event namespace if available (for multi-namespace support) 694 const eventNamespace = event._namespace !== undefined ? event._namespace : namespace; 695 696 // Check if this is a continuation (event started before this date) 697 const originalStartDate = event.originalStartDate || event._dateKey || date; 698 const isContinuation = originalStartDate < date; 699 700 // Convert to 12-hour format and handle time ranges 701 let displayTime = ''; 702 if (event.time) { 703 displayTime = formatTimeRange(event.time, event.endTime); 704 } 705 706 // Multi-day indicator 707 let multiDay = ''; 708 if (event.endDate && event.endDate !== date) { 709 const endObj = new Date(event.endDate + 'T00:00:00'); 710 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 711 month: 'short', 712 day: 'numeric' 713 }); 714 } 715 716 // Continuation message 717 if (isContinuation) { 718 const startObj = new Date(originalStartDate + 'T00:00:00'); 719 const startDisplay = startObj.toLocaleDateString('en-US', { 720 weekday: 'short', 721 month: 'short', 722 day: 'numeric' 723 }); 724 html += '<div class="popup-continuation-notice">↪ Continues from ' + startDisplay + '</div>'; 725 } 726 727 html += '<div class="popup-event-item">'; 728 html += '<div class="event-color-bar" style="background: ' + color + ';"></div>'; 729 html += '<div class="popup-event-content">'; 730 731 // Single line with title, time, date range, namespace, and actions 732 html += '<div class="popup-event-main-row">'; 733 html += '<div class="popup-event-info-inline">'; 734 html += '<span class="popup-event-title">' + escapeHtml(event.title) + '</span>'; 735 if (displayTime) { 736 html += '<span class="popup-event-time"> ' + displayTime + '</span>'; 737 } 738 if (multiDay) { 739 html += '<span class="popup-event-multiday">' + multiDay + '</span>'; 740 } 741 if (eventNamespace) { 742 html += '<span class="popup-event-namespace">' + escapeHtml(eventNamespace) + '</span>'; 743 } 744 745 // Add conflict warning badge if event has conflicts 746 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 747 // Build conflict list for tooltip 748 let conflictList = []; 749 event.conflictsWith.forEach(conflict => { 750 let conflictText = conflict.title; 751 if (conflict.time) { 752 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 753 } 754 conflictList.push(conflictText); 755 }); 756 757 html += '<span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 758 } 759 760 html += '</div>'; 761 html += '<div class="popup-event-actions">'; 762 html += '<button class="event-edit-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">✏️</button>'; 763 html += '<button class="event-delete-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">️</button>'; 764 html += '</div>'; 765 html += '</div>'; 766 767 // Description on separate line if present 768 if (event.description) { 769 html += '<div class="popup-event-desc">' + renderDescription(event.description) + '</div>'; 770 } 771 772 html += '</div></div>'; 773 }); 774 html += '</div>'; 775 } 776 777 html += '</div>'; 778 779 html += '<div class="day-popup-footer">'; 780 html += '<button class="btn-add-event" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\'); closeDayPopup(\'' + calId + '\')">+ Add Event</button>'; 781 html += '</div>'; 782 783 html += '</div>'; 784 785 popup.innerHTML = html; 786 popup.style.display = 'flex'; 787 788 // Propagate CSS vars from calendar container to popup (popup is outside container in DOM) 789 if (container) { 790 propagateThemeVars(calId, popup.querySelector('.day-popup-content')); 791 } 792 793 // Make popup draggable by header 794 const popupContent = popup.querySelector('.day-popup-content'); 795 const popupHeader = popup.querySelector('.day-popup-header'); 796 797 if (popupContent && popupHeader) { 798 // Reset position to center 799 popupContent.style.position = 'relative'; 800 popupContent.style.left = '0'; 801 popupContent.style.top = '0'; 802 803 // Store drag state on the element itself 804 popupHeader._isDragging = false; 805 806 popupHeader.onmousedown = function(e) { 807 // Ignore if clicking the close button 808 if (e.target.classList.contains('popup-close')) return; 809 810 popupHeader._isDragging = true; 811 popupHeader._dragStartX = e.clientX; 812 popupHeader._dragStartY = e.clientY; 813 814 const rect = popupContent.getBoundingClientRect(); 815 const parentRect = popup.getBoundingClientRect(); 816 popupHeader._initialLeft = rect.left - parentRect.left - (parentRect.width / 2 - rect.width / 2); 817 popupHeader._initialTop = rect.top - parentRect.top - (parentRect.height / 2 - rect.height / 2); 818 819 popupContent.style.transition = 'none'; 820 e.preventDefault(); 821 }; 822 823 popup.onmousemove = function(e) { 824 if (!popupHeader._isDragging) return; 825 826 const deltaX = e.clientX - popupHeader._dragStartX; 827 const deltaY = e.clientY - popupHeader._dragStartY; 828 829 popupContent.style.left = (popupHeader._initialLeft + deltaX) + 'px'; 830 popupContent.style.top = (popupHeader._initialTop + deltaY) + 'px'; 831 }; 832 833 popup.onmouseup = function() { 834 if (popupHeader._isDragging) { 835 popupHeader._isDragging = false; 836 popupContent.style.transition = ''; 837 } 838 }; 839 840 popup.onmouseleave = function() { 841 if (popupHeader._isDragging) { 842 popupHeader._isDragging = false; 843 popupContent.style.transition = ''; 844 } 845 }; 846 } 847}; 848 849// Close day popup 850window.closeDayPopup = function(calId) { 851 const popup = document.getElementById('day-popup-' + calId); 852 if (popup) { 853 popup.style.display = 'none'; 854 } 855}; 856 857// Show events for a specific day (for event list panel) 858window.showDayEvents = function(calId, date, namespace) { 859 const params = new URLSearchParams({ 860 call: 'plugin_calendar', 861 action: 'load_month', 862 year: date.split('-')[0], 863 month: parseInt(date.split('-')[1]), 864 namespace: namespace, 865 _: new Date().getTime() // Cache buster 866 }); 867 868 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 869 method: 'POST', 870 headers: { 871 'Content-Type': 'application/x-www-form-urlencoded', 872 'Cache-Control': 'no-cache, no-store, must-revalidate', 873 'Pragma': 'no-cache' 874 }, 875 body: params.toString() 876 }) 877 .then(r => r.json()) 878 .then(data => { 879 if (data.success) { 880 const eventList = document.getElementById('eventlist-' + calId); 881 const events = data.events; 882 const title = document.getElementById('eventlist-title-' + calId); 883 884 const dateObj = new Date(date + 'T00:00:00'); 885 const displayDate = dateObj.toLocaleDateString('en-US', { 886 weekday: 'short', 887 month: 'short', 888 day: 'numeric' 889 }); 890 891 title.textContent = 'Events - ' + displayDate; 892 893 // Filter events for this day 894 const dayEvents = events[date] || []; 895 896 if (dayEvents.length === 0) { 897 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>'; 898 } else { 899 let html = ''; 900 dayEvents.forEach(event => { 901 html += renderEventItem(event, date, calId, namespace); 902 }); 903 eventList.innerHTML = html; 904 } 905 } 906 }) 907 .catch(err => console.error('Error:', err)); 908}; 909 910// Render a single event item 911window.renderEventItem = function(event, date, calId, namespace) { 912 // Get theme data from container 913 const container = document.getElementById(calId); 914 let themeStyles = {}; 915 let importantNamespaces = ['important']; // default 916 if (container && container.dataset.themeStyles) { 917 try { 918 themeStyles = JSON.parse(container.dataset.themeStyles); 919 } catch (e) { 920 console.error('Failed to parse theme styles:', e); 921 } 922 } 923 // Get important namespaces from container data attribute 924 if (container && container.dataset.importantNamespaces) { 925 try { 926 importantNamespaces = JSON.parse(container.dataset.importantNamespaces); 927 } catch (e) { 928 importantNamespaces = ['important']; 929 } 930 } 931 932 // Check if this event is in the past or today (with 15-minute grace period) 933 const today = new Date(); 934 today.setHours(0, 0, 0, 0); 935 const todayStr = today.toISOString().split('T')[0]; 936 const eventDate = new Date(date + 'T00:00:00'); 937 938 // Helper to determine if event is past with grace period 939 let isPast; 940 if (date < todayStr) { 941 isPast = true; // Past date 942 } else if (date > todayStr) { 943 isPast = false; // Future date 944 } else { 945 // Today - check time with grace period 946 if (event.time && event.time.trim() !== '') { 947 try { 948 const now = new Date(); 949 const eventDateTime = new Date(date + 'T' + event.time); 950 const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000); 951 isPast = now > gracePeriodEnd; 952 } catch (e) { 953 isPast = false; 954 } 955 } else { 956 isPast = false; // No time, treat as future 957 } 958 } 959 960 const isToday = eventDate.getTime() === today.getTime(); 961 962 // Check if this is an important namespace event 963 let eventNamespace = event.namespace || ''; 964 if (!eventNamespace && event._namespace !== undefined) { 965 eventNamespace = event._namespace; 966 } 967 let isImportantNs = false; 968 if (eventNamespace) { 969 for (const impNs of importantNamespaces) { 970 if (eventNamespace === impNs || eventNamespace.startsWith(impNs + ':')) { 971 isImportantNs = true; 972 break; 973 } 974 } 975 } 976 977 // Format date display with day of week 978 const displayDateKey = event.originalStartDate || date; 979 const dateObj = new Date(displayDateKey + 'T00:00:00'); 980 const displayDate = dateObj.toLocaleDateString('en-US', { 981 weekday: 'short', 982 month: 'short', 983 day: 'numeric' 984 }); 985 986 // Convert to 12-hour format and handle time ranges 987 let displayTime = ''; 988 if (event.time) { 989 displayTime = formatTimeRange(event.time, event.endTime); 990 } 991 992 // Multi-day indicator 993 let multiDay = ''; 994 if (event.endDate && event.endDate !== displayDateKey) { 995 const endObj = new Date(event.endDate + 'T00:00:00'); 996 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 997 weekday: 'short', 998 month: 'short', 999 day: 'numeric' 1000 }); 1001 } 1002 1003 const completedClass = event.completed ? ' event-completed' : ''; 1004 const isTask = event.isTask || false; 1005 const completed = event.completed || false; 1006 const isPastDue = isPast && isTask && !completed; 1007 const pastClass = (isPast && !isPastDue) ? ' event-past' : ''; 1008 const pastDueClass = isPastDue ? ' event-pastdue' : ''; 1009 const importantClass = isImportantNs ? ' event-important' : ''; 1010 const color = event.color || '#3498db'; 1011 1012 // Only inline style needed: border-left-color for event color indicator 1013 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)' : '') + '">'; 1014 1015 html += '<div class="event-info">'; 1016 html += '<div class="event-title-row">'; 1017 // Add star for important namespace events 1018 if (isImportantNs) { 1019 html += '<span class="event-important-star" title="Important">⭐</span> '; 1020 } 1021 html += '<span class="event-title-compact">' + escapeHtml(event.title) + '</span>'; 1022 html += '</div>'; 1023 1024 // Show meta and description for non-past events AND past due tasks 1025 if (!isPast || isPastDue) { 1026 html += '<div class="event-meta-compact">'; 1027 html += '<span class="event-date-time">' + displayDate + multiDay; 1028 if (displayTime) { 1029 html += ' • ' + displayTime; 1030 } 1031 // Add PAST DUE or TODAY badge 1032 if (isPastDue) { 1033 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>'; 1034 } else if (isToday) { 1035 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>'; 1036 } 1037 // Add namespace badge 1038 if (eventNamespace) { 1039 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>'; 1040 } 1041 // Add conflict warning if event has time conflicts 1042 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 1043 let conflictList = []; 1044 event.conflictsWith.forEach(conflict => { 1045 let conflictText = conflict.title; 1046 if (conflict.time) { 1047 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 1048 } 1049 conflictList.push(conflictText); 1050 }); 1051 1052 html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 1053 } 1054 html += '</span>'; 1055 html += '</div>'; 1056 1057 if (event.description) { 1058 html += '<div class="event-desc-compact">' + renderDescription(event.description) + '</div>'; 1059 } 1060 } else { 1061 // For past events (not past due), store data in hidden divs for expand/collapse 1062 html += '<div class="event-meta-compact" style="display: none;">'; 1063 html += '<span class="event-date-time">' + displayDate + multiDay; 1064 if (displayTime) { 1065 html += ' • ' + displayTime; 1066 } 1067 // Add namespace badge for past events too 1068 let eventNamespace = event.namespace || ''; 1069 if (!eventNamespace && event._namespace !== undefined) { 1070 eventNamespace = event._namespace; 1071 } 1072 if (eventNamespace) { 1073 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>'; 1074 } 1075 // Add conflict warning for past events too 1076 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 1077 let conflictList = []; 1078 event.conflictsWith.forEach(conflict => { 1079 let conflictText = conflict.title; 1080 if (conflict.time) { 1081 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 1082 } 1083 conflictList.push(conflictText); 1084 }); 1085 1086 html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 1087 } 1088 html += '</span>'; 1089 html += '</div>'; 1090 1091 if (event.description) { 1092 html += '<div class="event-desc-compact" style="display: none;">' + renderDescription(event.description) + '</div>'; 1093 } 1094 } 1095 1096 html += '</div>'; // event-info 1097 1098 // Use stored namespace from event, fallback to _namespace, then passed namespace 1099 let buttonNamespace = event.namespace || ''; 1100 if (!buttonNamespace && event._namespace !== undefined) { 1101 buttonNamespace = event._namespace; 1102 } 1103 if (!buttonNamespace) { 1104 buttonNamespace = namespace; 1105 } 1106 1107 html += '<div class="event-actions-compact">'; 1108 html += '<button class="event-action-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">️</button>'; 1109 html += '<button class="event-action-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">✏️</button>'; 1110 html += '</div>'; 1111 1112 // Checkbox for tasks - ON THE FAR RIGHT 1113 if (isTask) { 1114 const checked = completed ? 'checked' : ''; 1115 html += '<input type="checkbox" class="task-checkbox" ' + checked + ' onclick="toggleTaskComplete(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\', this.checked)">'; 1116 } 1117 1118 html += '</div>'; 1119 1120 return html; 1121}; 1122 1123// Render description with rich content support 1124window.renderDescription = function(description) { 1125 if (!description) return ''; 1126 1127 // First, convert DokuWiki/Markdown syntax to placeholder tokens (before escaping) 1128 // Use a format that won't be affected by HTML escaping: \x00TOKEN_N\x00 1129 1130 let rendered = description; 1131 const tokens = []; 1132 let tokenIndex = 0; 1133 1134 // Convert DokuWiki image syntax {{image.jpg}} to tokens 1135 rendered = rendered.replace(/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/g, function(match, imagePath, alt) { 1136 imagePath = imagePath.trim(); 1137 alt = alt ? alt.trim() : ''; 1138 1139 let imageHtml; 1140 // Handle external URLs 1141 if (imagePath.match(/^https?:\/\//)) { 1142 imageHtml = '<img src="' + imagePath + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 1143 } else { 1144 // Handle internal DokuWiki images 1145 const imageUrl = DOKU_BASE + 'lib/exe/fetch.php?media=' + encodeURIComponent(imagePath); 1146 imageHtml = '<img src="' + imageUrl + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 1147 } 1148 1149 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1150 tokens[tokenIndex] = imageHtml; 1151 tokenIndex++; 1152 return token; 1153 }); 1154 1155 // Convert DokuWiki link syntax [[link|text]] to tokens 1156 rendered = rendered.replace(/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/g, function(match, link, text) { 1157 link = link.trim(); 1158 text = text ? text.trim() : link; 1159 1160 let linkHtml; 1161 // Handle external URLs 1162 if (link.match(/^https?:\/\//)) { 1163 linkHtml = '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 1164 } else { 1165 // Handle internal DokuWiki links with section anchors 1166 const hashIndex = link.indexOf('#'); 1167 let pagePart = link; 1168 let sectionPart = ''; 1169 1170 if (hashIndex !== -1) { 1171 pagePart = link.substring(0, hashIndex); 1172 sectionPart = link.substring(hashIndex); // Includes the # 1173 } 1174 1175 const wikiUrl = DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pagePart) + sectionPart; 1176 linkHtml = '<a href="' + wikiUrl + '">' + escapeHtml(text) + '</a>'; 1177 } 1178 1179 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1180 tokens[tokenIndex] = linkHtml; 1181 tokenIndex++; 1182 return token; 1183 }); 1184 1185 // Convert markdown-style links [text](url) to tokens 1186 rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) { 1187 text = text.trim(); 1188 url = url.trim(); 1189 1190 let linkHtml; 1191 if (url.match(/^https?:\/\//)) { 1192 linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 1193 } else { 1194 linkHtml = '<a href="' + escapeHtml(url) + '">' + escapeHtml(text) + '</a>'; 1195 } 1196 1197 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1198 tokens[tokenIndex] = linkHtml; 1199 tokenIndex++; 1200 return token; 1201 }); 1202 1203 // Convert plain URLs to tokens 1204 rendered = rendered.replace(/(https?:\/\/[^\s<]+)/g, function(match, url) { 1205 const linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(url) + '</a>'; 1206 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1207 tokens[tokenIndex] = linkHtml; 1208 tokenIndex++; 1209 return token; 1210 }); 1211 1212 // NOW escape the remaining text (tokens are protected with null bytes) 1213 rendered = escapeHtml(rendered); 1214 1215 // Convert newlines to <br> 1216 rendered = rendered.replace(/\n/g, '<br>'); 1217 1218 // DokuWiki text formatting (on escaped text) 1219 // Bold: **text** or __text__ 1220 rendered = rendered.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 1221 rendered = rendered.replace(/__(.+?)__/g, '<strong>$1</strong>'); 1222 1223 // Italic: //text// 1224 rendered = rendered.replace(/\/\/(.+?)\/\//g, '<em>$1</em>'); 1225 1226 // Strikethrough: <del>text</del> 1227 rendered = rendered.replace(/<del>(.+?)<\/del>/g, '<del>$1</del>'); 1228 1229 // Monospace: ''text'' 1230 rendered = rendered.replace(/''(.+?)''/g, '<code>$1</code>'); 1231 1232 // Subscript: <sub>text</sub> 1233 rendered = rendered.replace(/<sub>(.+?)<\/sub>/g, '<sub>$1</sub>'); 1234 1235 // Superscript: <sup>text</sup> 1236 rendered = rendered.replace(/<sup>(.+?)<\/sup>/g, '<sup>$1</sup>'); 1237 1238 // Restore tokens (replace with actual HTML) 1239 for (let i = 0; i < tokens.length; i++) { 1240 const tokenPattern = new RegExp('\x00TOKEN' + i + '\x00', 'g'); 1241 rendered = rendered.replace(tokenPattern, tokens[i]); 1242 } 1243 1244 return rendered; 1245} 1246 1247// Open add event dialog 1248window.openAddEvent = function(calId, namespace, date) { 1249 const dialog = document.getElementById('dialog-' + calId); 1250 const form = document.getElementById('eventform-' + calId); 1251 const title = document.getElementById('dialog-title-' + calId); 1252 const dateField = document.getElementById('event-date-' + calId); 1253 1254 if (!dateField) { 1255 console.error('Date field not found! ID: event-date-' + calId); 1256 return; 1257 } 1258 1259 // Check if there's a filtered namespace active (only for regular calendars) 1260 const calendar = document.getElementById(calId); 1261 const filteredNamespace = calendar ? calendar.dataset.filteredNamespace : null; 1262 1263 // Use filtered namespace if available, otherwise use the passed namespace 1264 const effectiveNamespace = filteredNamespace || namespace; 1265 1266 1267 // Reset form 1268 form.reset(); 1269 document.getElementById('event-id-' + calId).value = ''; 1270 1271 // Store the effective namespace in a hidden field or data attribute 1272 form.dataset.effectiveNamespace = effectiveNamespace; 1273 1274 // Set namespace dropdown to effective namespace 1275 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1276 if (namespaceSelect) { 1277 if (effectiveNamespace && effectiveNamespace !== '*' && effectiveNamespace.indexOf(';') === -1) { 1278 // Set to specific namespace if not wildcard or multi-namespace 1279 namespaceSelect.value = effectiveNamespace; 1280 } else { 1281 // Default to empty (default namespace) for wildcard/multi views 1282 namespaceSelect.value = ''; 1283 } 1284 } 1285 1286 // Clear event namespace from previous edits 1287 delete form.dataset.eventNamespace; 1288 1289 // Set date - use local date, not UTC 1290 let defaultDate = date; 1291 if (!defaultDate) { 1292 // Get the currently displayed month from the calendar container 1293 const container = document.getElementById(calId); 1294 const displayedYear = parseInt(container.getAttribute('data-year')); 1295 const displayedMonth = parseInt(container.getAttribute('data-month')); 1296 1297 1298 if (displayedYear && displayedMonth) { 1299 // Use first day of the displayed month 1300 const year = displayedYear; 1301 const month = String(displayedMonth).padStart(2, '0'); 1302 defaultDate = `${year}-${month}-01`; 1303 } else { 1304 // Fallback to today if attributes not found 1305 const today = new Date(); 1306 const year = today.getFullYear(); 1307 const month = String(today.getMonth() + 1).padStart(2, '0'); 1308 const day = String(today.getDate()).padStart(2, '0'); 1309 defaultDate = `${year}-${month}-${day}`; 1310 } 1311 } 1312 dateField.value = defaultDate; 1313 dateField.removeAttribute('data-original-date'); 1314 1315 // Also set the end date field to the same default (user can change it) 1316 const endDateField = document.getElementById('event-end-date-' + calId); 1317 if (endDateField) { 1318 endDateField.value = ''; // Empty by default (single-day event) 1319 // Set min attribute to help the date picker open on the right month 1320 endDateField.setAttribute('min', defaultDate); 1321 } 1322 1323 // Set default color 1324 document.getElementById('event-color-' + calId).value = '#3498db'; 1325 1326 // Initialize end time dropdown (disabled by default since no start time set) 1327 const endTimeField = document.getElementById('event-end-time-' + calId); 1328 if (endTimeField) { 1329 endTimeField.disabled = true; 1330 endTimeField.value = ''; 1331 } 1332 1333 // Initialize namespace search 1334 initNamespaceSearch(calId); 1335 1336 // Set title 1337 title.textContent = 'Add Event'; 1338 1339 // Show dialog 1340 dialog.style.display = 'flex'; 1341 1342 // Propagate CSS vars to dialog (position:fixed can break inheritance in some templates) 1343 propagateThemeVars(calId, dialog); 1344 1345 // Focus title field 1346 setTimeout(() => { 1347 const titleField = document.getElementById('event-title-' + calId); 1348 if (titleField) titleField.focus(); 1349 }, 100); 1350}; 1351 1352// Edit event 1353window.editEvent = function(calId, eventId, date, namespace) { 1354 const params = new URLSearchParams({ 1355 call: 'plugin_calendar', 1356 action: 'get_event', 1357 namespace: namespace, 1358 date: date, 1359 eventId: eventId 1360 }); 1361 1362 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1363 method: 'POST', 1364 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1365 body: params.toString() 1366 }) 1367 .then(r => r.json()) 1368 .then(data => { 1369 if (data.success && data.event) { 1370 const event = data.event; 1371 const dialog = document.getElementById('dialog-' + calId); 1372 const title = document.getElementById('dialog-title-' + calId); 1373 const dateField = document.getElementById('event-date-' + calId); 1374 const form = document.getElementById('eventform-' + calId); 1375 1376 if (!dateField) { 1377 console.error('Date field not found when editing!'); 1378 return; 1379 } 1380 1381 // Store the event's actual namespace for saving (important for namespace=* views) 1382 if (event.namespace !== undefined) { 1383 form.dataset.eventNamespace = event.namespace; 1384 } 1385 1386 // Populate form 1387 document.getElementById('event-id-' + calId).value = event.id; 1388 dateField.value = date; 1389 dateField.setAttribute('data-original-date', date); 1390 1391 const endDateField = document.getElementById('event-end-date-' + calId); 1392 endDateField.value = event.endDate || ''; 1393 // Set min attribute to help date picker open on the start date's month 1394 endDateField.setAttribute('min', date); 1395 1396 document.getElementById('event-title-' + calId).value = event.title; 1397 document.getElementById('event-time-' + calId).value = event.time || ''; 1398 document.getElementById('event-end-time-' + calId).value = event.endTime || ''; 1399 document.getElementById('event-color-' + calId).value = event.color || '#3498db'; 1400 document.getElementById('event-desc-' + calId).value = event.description || ''; 1401 document.getElementById('event-is-task-' + calId).checked = event.isTask || false; 1402 1403 // Update end time options based on start time 1404 if (event.time) { 1405 updateEndTimeOptions(calId); 1406 } 1407 1408 // Initialize namespace search 1409 initNamespaceSearch(calId); 1410 1411 // Set namespace fields if available 1412 const namespaceHidden = document.getElementById('event-namespace-' + calId); 1413 const namespaceSearch = document.getElementById('event-namespace-search-' + calId); 1414 if (namespaceHidden && event.namespace !== undefined) { 1415 // Set the hidden input (this is what gets submitted) 1416 namespaceHidden.value = event.namespace || ''; 1417 // Set the search input to display the namespace 1418 if (namespaceSearch) { 1419 namespaceSearch.value = event.namespace || '(default)'; 1420 } 1421 } else { 1422 // No namespace on event, set to default 1423 if (namespaceHidden) { 1424 namespaceHidden.value = ''; 1425 } 1426 if (namespaceSearch) { 1427 namespaceSearch.value = '(default)'; 1428 } 1429 } 1430 1431 title.textContent = 'Edit Event'; 1432 dialog.style.display = 'flex'; 1433 1434 // Propagate CSS vars to dialog 1435 propagateThemeVars(calId, dialog); 1436 } 1437 }) 1438 .catch(err => console.error('Error editing event:', err)); 1439}; 1440 1441// Delete event 1442window.deleteEvent = function(calId, eventId, date, namespace) { 1443 if (!confirm('Delete this event?')) return; 1444 1445 const params = new URLSearchParams({ 1446 call: 'plugin_calendar', 1447 action: 'delete_event', 1448 namespace: namespace, 1449 date: date, 1450 eventId: eventId, 1451 sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : '' 1452 }); 1453 1454 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1455 method: 'POST', 1456 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1457 body: params.toString() 1458 }) 1459 .then(r => r.json()) 1460 .then(data => { 1461 if (data.success) { 1462 // Extract year and month from date 1463 const [year, month] = date.split('-').map(Number); 1464 1465 // Get the calendar's ORIGINAL namespace setting (not the deleted event's namespace) 1466 // This preserves wildcard/multi-namespace views 1467 const container = document.getElementById(calId); 1468 const calendarNamespace = container ? (container.dataset.namespace || '') : namespace; 1469 1470 // Reload calendar data via AJAX with the calendar's original namespace 1471 reloadCalendarData(calId, year, month, calendarNamespace); 1472 } 1473 }) 1474 .catch(err => console.error('Error:', err)); 1475}; 1476 1477// Save event (add or edit) 1478window.saveEventCompact = function(calId, namespace) { 1479 const form = document.getElementById('eventform-' + calId); 1480 1481 // Get namespace from dropdown - this is what the user selected 1482 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1483 const selectedNamespace = namespaceSelect ? namespaceSelect.value : ''; 1484 1485 // ALWAYS use what the user selected in the dropdown 1486 // This allows changing namespace when editing 1487 const finalNamespace = selectedNamespace; 1488 1489 const eventId = document.getElementById('event-id-' + calId).value; 1490 1491 // eventNamespace is the ORIGINAL namespace (only used for finding/deleting old event) 1492 const originalNamespace = form.dataset.eventNamespace; 1493 1494 1495 const dateInput = document.getElementById('event-date-' + calId); 1496 const date = dateInput.value; 1497 const oldDate = dateInput.getAttribute('data-original-date') || date; 1498 const endDate = document.getElementById('event-end-date-' + calId).value; 1499 const title = document.getElementById('event-title-' + calId).value; 1500 const time = document.getElementById('event-time-' + calId).value; 1501 const endTime = document.getElementById('event-end-time-' + calId).value; 1502 const colorSelect = document.getElementById('event-color-' + calId); 1503 let color = colorSelect.value; 1504 1505 // Handle custom color 1506 if (color === 'custom') { 1507 color = colorSelect.dataset.customColor || document.getElementById('event-color-custom-' + calId).value; 1508 } 1509 1510 const description = document.getElementById('event-desc-' + calId).value; 1511 const isTask = document.getElementById('event-is-task-' + calId).checked; 1512 const completed = false; // New tasks are not completed 1513 const isRecurring = document.getElementById('event-recurring-' + calId).checked; 1514 const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value; 1515 const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value; 1516 1517 // New recurrence options 1518 const recurrenceIntervalInput = document.getElementById('event-recurrence-interval-' + calId); 1519 const recurrenceInterval = recurrenceIntervalInput ? parseInt(recurrenceIntervalInput.value) || 1 : 1; 1520 1521 // Weekly: collect selected days 1522 let weekDays = []; 1523 const weeklyOptions = document.getElementById('weekly-options-' + calId); 1524 if (weeklyOptions && recurrenceType === 'weekly') { 1525 const checkboxes = weeklyOptions.querySelectorAll('input[name="weekDays[]"]:checked'); 1526 weekDays = Array.from(checkboxes).map(cb => cb.value); 1527 } 1528 1529 // Monthly: collect day-of-month or ordinal weekday 1530 let monthDay = ''; 1531 let monthlyType = 'dayOfMonth'; 1532 let ordinalWeek = ''; 1533 let ordinalDay = ''; 1534 const monthlyOptions = document.getElementById('monthly-options-' + calId); 1535 if (monthlyOptions && recurrenceType === 'monthly') { 1536 const monthlyTypeRadio = monthlyOptions.querySelector('input[name="monthlyType"]:checked'); 1537 monthlyType = monthlyTypeRadio ? monthlyTypeRadio.value : 'dayOfMonth'; 1538 1539 if (monthlyType === 'dayOfMonth') { 1540 const monthDayInput = document.getElementById('event-month-day-' + calId); 1541 monthDay = monthDayInput ? monthDayInput.value : ''; 1542 } else { 1543 const ordinalSelect = document.getElementById('event-ordinal-' + calId); 1544 const ordinalDaySelect = document.getElementById('event-ordinal-day-' + calId); 1545 ordinalWeek = ordinalSelect ? ordinalSelect.value : '1'; 1546 ordinalDay = ordinalDaySelect ? ordinalDaySelect.value : '0'; 1547 } 1548 } 1549 1550 if (!title) { 1551 alert('Please enter a title'); 1552 return; 1553 } 1554 1555 if (!date) { 1556 alert('Please select a date'); 1557 return; 1558 } 1559 1560 const params = new URLSearchParams({ 1561 call: 'plugin_calendar', 1562 action: 'save_event', 1563 namespace: finalNamespace, 1564 eventId: eventId, 1565 date: date, 1566 oldDate: oldDate, 1567 endDate: endDate, 1568 title: title, 1569 time: time, 1570 endTime: endTime, 1571 color: color, 1572 description: description, 1573 isTask: isTask ? '1' : '0', 1574 completed: completed ? '1' : '0', 1575 isRecurring: isRecurring ? '1' : '0', 1576 recurrenceType: recurrenceType, 1577 recurrenceInterval: recurrenceInterval, 1578 recurrenceEnd: recurrenceEnd, 1579 weekDays: weekDays.join(','), 1580 monthlyType: monthlyType, 1581 monthDay: monthDay, 1582 ordinalWeek: ordinalWeek, 1583 ordinalDay: ordinalDay, 1584 sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : '' 1585 }); 1586 1587 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1588 method: 'POST', 1589 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1590 body: params.toString() 1591 }) 1592 .then(r => r.json()) 1593 .then(data => { 1594 if (data.success) { 1595 closeEventDialog(calId); 1596 1597 // For recurring events, do a full page reload to show all occurrences 1598 if (isRecurring) { 1599 location.reload(); 1600 return; 1601 } 1602 1603 // Extract year and month from the NEW date (in case date was changed) 1604 const [year, month] = date.split('-').map(Number); 1605 1606 // Get the calendar's ORIGINAL namespace setting from the container 1607 // This preserves wildcard/multi-namespace views after editing 1608 const container = document.getElementById(calId); 1609 const calendarNamespace = container ? (container.dataset.namespace || '') : namespace; 1610 1611 // Reload calendar data via AJAX to the month of the event 1612 reloadCalendarData(calId, year, month, calendarNamespace); 1613 } else { 1614 alert('Error: ' + (data.error || 'Unknown error')); 1615 } 1616 }) 1617 .catch(err => { 1618 console.error('Error:', err); 1619 alert('Error saving event'); 1620 }); 1621}; 1622 1623// Reload calendar data without page refresh 1624window.reloadCalendarData = function(calId, year, month, namespace) { 1625 const params = new URLSearchParams({ 1626 call: 'plugin_calendar', 1627 action: 'load_month', 1628 year: year, 1629 month: month, 1630 namespace: namespace, 1631 _: new Date().getTime() // Cache buster 1632 }); 1633 1634 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1635 method: 'POST', 1636 headers: { 1637 'Content-Type': 'application/x-www-form-urlencoded', 1638 'Cache-Control': 'no-cache, no-store, must-revalidate', 1639 'Pragma': 'no-cache' 1640 }, 1641 body: params.toString() 1642 }) 1643 .then(r => r.json()) 1644 .then(data => { 1645 if (data.success) { 1646 const container = document.getElementById(calId); 1647 1648 // Check if this is a full calendar or just event panel 1649 if (container.classList.contains('calendar-compact-container')) { 1650 rebuildCalendar(calId, data.year, data.month, data.events, namespace); 1651 } else if (container.classList.contains('event-panel-standalone')) { 1652 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1653 } 1654 } 1655 }) 1656 .catch(err => console.error('Error:', err)); 1657}; 1658 1659// Close event dialog 1660window.closeEventDialog = function(calId) { 1661 const dialog = document.getElementById('dialog-' + calId); 1662 dialog.style.display = 'none'; 1663}; 1664 1665// Escape HTML 1666window.escapeHtml = function(text) { 1667 const div = document.createElement('div'); 1668 div.textContent = text; 1669 return div.innerHTML; 1670}; 1671 1672// Highlight event when clicking on bar in calendar 1673window.highlightEvent = function(calId, eventId, date) { 1674 1675 // Find the event item in the event list 1676 const eventList = document.querySelector('#' + calId + ' .event-list-compact'); 1677 if (!eventList) { 1678 return; 1679 } 1680 1681 const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]'); 1682 if (!eventItem) { 1683 return; 1684 } 1685 1686 1687 // Get theme 1688 const container = document.getElementById(calId); 1689 const theme = container ? container.dataset.theme : 'matrix'; 1690 const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {}; 1691 1692 1693 // Theme-specific highlight colors 1694 let highlightBg, highlightShadow; 1695 if (theme === 'matrix') { 1696 highlightBg = '#1a3d1a'; // Darker green 1697 highlightShadow = '0 0 20px rgba(0, 204, 7, 0.8), 0 0 40px rgba(0, 204, 7, 0.4)'; 1698 } else if (theme === 'purple') { 1699 highlightBg = '#3d2b4d'; // Darker purple 1700 highlightShadow = '0 0 20px rgba(155, 89, 182, 0.8), 0 0 40px rgba(155, 89, 182, 0.4)'; 1701 } else if (theme === 'professional') { 1702 highlightBg = '#e3f2fd'; // Light blue 1703 highlightShadow = '0 0 20px rgba(74, 144, 226, 0.4)'; 1704 } else if (theme === 'pink') { 1705 highlightBg = '#3d2030'; // Darker pink 1706 highlightShadow = '0 0 20px rgba(255, 20, 147, 0.8), 0 0 40px rgba(255, 20, 147, 0.4)'; 1707 } else if (theme === 'wiki') { 1708 highlightBg = themeStyles.header_bg || '#e8e8e8'; // __background_alt__ 1709 highlightShadow = '0 0 10px rgba(0, 0, 0, 0.15)'; 1710 } 1711 1712 1713 // Store original styles 1714 const originalBg = eventItem.style.background; 1715 const originalShadow = eventItem.style.boxShadow; 1716 1717 // Remove previous highlights (restore their original styles) 1718 const previousHighlights = eventList.querySelectorAll('.event-highlighted'); 1719 previousHighlights.forEach(el => { 1720 el.classList.remove('event-highlighted'); 1721 }); 1722 1723 // Add highlight class and apply theme-aware glow 1724 eventItem.classList.add('event-highlighted'); 1725 1726 // Set CSS properties directly 1727 eventItem.style.setProperty('background', highlightBg, 'important'); 1728 eventItem.style.setProperty('box-shadow', highlightShadow, 'important'); 1729 eventItem.style.setProperty('transition', 'all 0.3s ease-in-out', 'important'); 1730 1731 1732 // Scroll to event 1733 eventItem.scrollIntoView({ 1734 behavior: 'smooth', 1735 block: 'nearest', 1736 inline: 'nearest' 1737 }); 1738 1739 // Remove highlight after 3 seconds and restore original styles 1740 setTimeout(() => { 1741 eventItem.classList.remove('event-highlighted'); 1742 eventItem.style.setProperty('background', originalBg); 1743 eventItem.style.setProperty('box-shadow', originalShadow); 1744 eventItem.style.setProperty('transition', ''); 1745 }, 3000); 1746}; 1747 1748// Toggle recurring event options 1749window.toggleRecurringOptions = function(calId) { 1750 const checkbox = document.getElementById('event-recurring-' + calId); 1751 const options = document.getElementById('recurring-options-' + calId); 1752 1753 if (checkbox && options) { 1754 options.style.display = checkbox.checked ? 'block' : 'none'; 1755 if (checkbox.checked) { 1756 // Initialize the sub-options based on current selection 1757 updateRecurrenceOptions(calId); 1758 } 1759 } 1760}; 1761 1762// Update visible recurrence options based on type (daily/weekly/monthly/yearly) 1763window.updateRecurrenceOptions = function(calId) { 1764 const typeSelect = document.getElementById('event-recurrence-type-' + calId); 1765 const weeklyOptions = document.getElementById('weekly-options-' + calId); 1766 const monthlyOptions = document.getElementById('monthly-options-' + calId); 1767 1768 if (!typeSelect) return; 1769 1770 const recurrenceType = typeSelect.value; 1771 1772 // Hide all conditional options first 1773 if (weeklyOptions) weeklyOptions.style.display = 'none'; 1774 if (monthlyOptions) monthlyOptions.style.display = 'none'; 1775 1776 // Show relevant options 1777 if (recurrenceType === 'weekly' && weeklyOptions) { 1778 weeklyOptions.style.display = 'block'; 1779 // Auto-select today's day of week if nothing selected 1780 const checkboxes = weeklyOptions.querySelectorAll('input[type="checkbox"]'); 1781 const anyChecked = Array.from(checkboxes).some(cb => cb.checked); 1782 if (!anyChecked) { 1783 const today = new Date().getDay(); 1784 const todayCheckbox = weeklyOptions.querySelector('input[value="' + today + '"]'); 1785 if (todayCheckbox) todayCheckbox.checked = true; 1786 } 1787 } else if (recurrenceType === 'monthly' && monthlyOptions) { 1788 monthlyOptions.style.display = 'block'; 1789 // Set default day to current day of month 1790 const monthDayInput = document.getElementById('event-month-day-' + calId); 1791 if (monthDayInput && !monthDayInput.dataset.userSet) { 1792 monthDayInput.value = new Date().getDate(); 1793 } 1794 } 1795}; 1796 1797// Toggle between day-of-month and ordinal weekday for monthly recurrence 1798window.updateMonthlyType = function(calId) { 1799 const dayOfMonthDiv = document.getElementById('monthly-day-' + calId); 1800 const ordinalDiv = document.getElementById('monthly-ordinal-' + calId); 1801 const monthlyOptions = document.getElementById('monthly-options-' + calId); 1802 1803 if (!monthlyOptions) return; 1804 1805 const selectedRadio = monthlyOptions.querySelector('input[name="monthlyType"]:checked'); 1806 if (!selectedRadio) return; 1807 1808 if (selectedRadio.value === 'dayOfMonth') { 1809 if (dayOfMonthDiv) dayOfMonthDiv.style.display = 'flex'; 1810 if (ordinalDiv) ordinalDiv.style.display = 'none'; 1811 } else { 1812 if (dayOfMonthDiv) dayOfMonthDiv.style.display = 'none'; 1813 if (ordinalDiv) ordinalDiv.style.display = 'block'; 1814 1815 // Set defaults based on current date 1816 const now = new Date(); 1817 const dayOfWeek = now.getDay(); 1818 const weekOfMonth = Math.ceil(now.getDate() / 7); 1819 1820 const ordinalSelect = document.getElementById('event-ordinal-' + calId); 1821 const ordinalDaySelect = document.getElementById('event-ordinal-day-' + calId); 1822 1823 if (ordinalSelect && !ordinalSelect.dataset.userSet) { 1824 ordinalSelect.value = weekOfMonth; 1825 } 1826 if (ordinalDaySelect && !ordinalDaySelect.dataset.userSet) { 1827 ordinalDaySelect.value = dayOfWeek; 1828 } 1829 } 1830}; 1831 1832// ============================================================ 1833// Document-level event delegation (guarded - only attach once) 1834// These use event delegation so they work for AJAX-rebuilt content. 1835// ============================================================ 1836if (!window._calendarDelegationInit) { 1837 window._calendarDelegationInit = true; 1838 1839 // ESC closes dialogs, popups, tooltips 1840 document.addEventListener('keydown', function(e) { 1841 if (e.key === 'Escape') { 1842 document.querySelectorAll('.event-dialog-compact').forEach(function(d) { 1843 if (d.style.display === 'flex') d.style.display = 'none'; 1844 }); 1845 document.querySelectorAll('.day-popup').forEach(function(p) { 1846 p.style.display = 'none'; 1847 }); 1848 hideConflictTooltip(); 1849 } 1850 }); 1851 1852 // Conflict tooltip delegation (capture phase for mouseenter/leave) 1853 document.addEventListener('mouseenter', function(e) { 1854 if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) { 1855 showConflictTooltip(e.target); 1856 } 1857 }, true); 1858 1859 document.addEventListener('mouseleave', function(e) { 1860 if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) { 1861 hideConflictTooltip(); 1862 } 1863 }, true); 1864} // end delegation guard 1865 1866// Event panel navigation 1867window.navEventPanel = function(calId, year, month, namespace) { 1868 const params = new URLSearchParams({ 1869 call: 'plugin_calendar', 1870 action: 'load_month', 1871 year: year, 1872 month: month, 1873 namespace: namespace, 1874 _: new Date().getTime() // Cache buster 1875 }); 1876 1877 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1878 method: 'POST', 1879 headers: { 1880 'Content-Type': 'application/x-www-form-urlencoded', 1881 'Cache-Control': 'no-cache, no-store, must-revalidate', 1882 'Pragma': 'no-cache' 1883 }, 1884 body: params.toString() 1885 }) 1886 .then(r => r.json()) 1887 .then(data => { 1888 if (data.success) { 1889 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1890 } 1891 }) 1892 .catch(err => console.error('Error:', err)); 1893}; 1894 1895// Rebuild event panel only 1896window.rebuildEventPanel = function(calId, year, month, events, namespace) { 1897 const container = document.getElementById(calId); 1898 const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 1899 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 1900 1901 // Update month title in new compact header 1902 const monthTitle = container.querySelector('.panel-month-title'); 1903 if (monthTitle) { 1904 monthTitle.textContent = monthNames[month - 1] + ' ' + year; 1905 monthTitle.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1906 monthTitle.setAttribute('title', 'Click to jump to month'); 1907 } 1908 1909 // Fallback: Update old header format if exists 1910 const oldHeader = container.querySelector('.panel-standalone-header h3, .calendar-month-picker'); 1911 if (oldHeader && !monthTitle) { 1912 oldHeader.textContent = monthNames[month - 1] + ' ' + year + ' Events'; 1913 oldHeader.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1914 } 1915 1916 // Update nav buttons 1917 let prevMonth = month - 1; 1918 let prevYear = year; 1919 if (prevMonth < 1) { 1920 prevMonth = 12; 1921 prevYear--; 1922 } 1923 1924 let nextMonth = month + 1; 1925 let nextYear = year; 1926 if (nextMonth > 12) { 1927 nextMonth = 1; 1928 nextYear++; 1929 } 1930 1931 // Update new compact nav buttons 1932 const navBtns = container.querySelectorAll('.panel-nav-btn'); 1933 if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 1934 if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 1935 1936 // Fallback for old nav buttons 1937 const oldNavBtns = container.querySelectorAll('.cal-nav-btn'); 1938 if (oldNavBtns.length > 0 && navBtns.length === 0) { 1939 if (oldNavBtns[0]) oldNavBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 1940 if (oldNavBtns[1]) oldNavBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 1941 } 1942 1943 // Update Today button (works for both old and new) 1944 const todayBtn = container.querySelector('.panel-today-btn, .cal-today-btn, .cal-today-btn-compact'); 1945 if (todayBtn) { 1946 todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`); 1947 } 1948 1949 // Rebuild event list 1950 const eventList = container.querySelector('.event-list-compact'); 1951 if (eventList) { 1952 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 1953 } 1954}; 1955 1956// Open add event for panel 1957window.openAddEventPanel = function(calId, namespace) { 1958 const today = new Date(); 1959 const year = today.getFullYear(); 1960 const month = String(today.getMonth() + 1).padStart(2, '0'); 1961 const day = String(today.getDate()).padStart(2, '0'); 1962 const localDate = `${year}-${month}-${day}`; 1963 openAddEvent(calId, namespace, localDate); 1964}; 1965 1966// Toggle task completion 1967window.toggleTaskComplete = function(calId, eventId, date, namespace, completed) { 1968 const params = new URLSearchParams({ 1969 call: 'plugin_calendar', 1970 action: 'toggle_task', 1971 namespace: namespace, 1972 date: date, 1973 eventId: eventId, 1974 completed: completed ? '1' : '0', 1975 sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : '' 1976 }); 1977 1978 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1979 method: 'POST', 1980 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1981 body: params.toString() 1982 }) 1983 .then(r => r.json()) 1984 .then(data => { 1985 if (data.success) { 1986 const [year, month] = date.split('-').map(Number); 1987 1988 // Get the calendar's ORIGINAL namespace setting from the container 1989 const container = document.getElementById(calId); 1990 const calendarNamespace = container ? (container.dataset.namespace || '') : namespace; 1991 1992 reloadCalendarData(calId, year, month, calendarNamespace); 1993 } 1994 }) 1995 .catch(err => console.error('Error toggling task:', err)); 1996}; 1997 1998// Make dialog draggable 1999window.makeDialogDraggable = function(calId) { 2000 const dialog = document.getElementById('dialog-content-' + calId); 2001 const handle = document.getElementById('drag-handle-' + calId); 2002 2003 if (!dialog || !handle) return; 2004 2005 let isDragging = false; 2006 let currentX; 2007 let currentY; 2008 let initialX; 2009 let initialY; 2010 let xOffset = 0; 2011 let yOffset = 0; 2012 2013 handle.addEventListener('mousedown', dragStart); 2014 document.addEventListener('mousemove', drag); 2015 document.addEventListener('mouseup', dragEnd); 2016 2017 function dragStart(e) { 2018 initialX = e.clientX - xOffset; 2019 initialY = e.clientY - yOffset; 2020 isDragging = true; 2021 } 2022 2023 function drag(e) { 2024 if (isDragging) { 2025 e.preventDefault(); 2026 currentX = e.clientX - initialX; 2027 currentY = e.clientY - initialY; 2028 xOffset = currentX; 2029 yOffset = currentY; 2030 setTranslate(currentX, currentY, dialog); 2031 } 2032 } 2033 2034 function dragEnd(e) { 2035 initialX = currentX; 2036 initialY = currentY; 2037 isDragging = false; 2038 } 2039 2040 function setTranslate(xPos, yPos, el) { 2041 el.style.transform = `translate(${xPos}px, ${yPos}px)`; 2042 } 2043}; 2044 2045// Initialize dialog draggability when opened (avoid duplicate declaration) 2046if (!window.calendarDraggabilityPatched) { 2047 window.calendarDraggabilityPatched = true; 2048 2049 const originalOpenAddEvent = openAddEvent; 2050 openAddEvent = function(calId, namespace, date) { 2051 originalOpenAddEvent(calId, namespace, date); 2052 setTimeout(() => makeDialogDraggable(calId), 100); 2053 }; 2054 2055 const originalEditEvent = editEvent; 2056 editEvent = function(calId, eventId, date, namespace) { 2057 originalEditEvent(calId, eventId, date, namespace); 2058 setTimeout(() => makeDialogDraggable(calId), 100); 2059 }; 2060} 2061 2062// Toggle expand/collapse for past events 2063window.togglePastEventExpand = function(element) { 2064 // Stop propagation to prevent any parent click handlers 2065 event.stopPropagation(); 2066 2067 const meta = element.querySelector(".event-meta-compact"); 2068 const desc = element.querySelector(".event-desc-compact"); 2069 2070 // Toggle visibility 2071 if (meta.style.display === "none") { 2072 // Expand 2073 meta.style.display = "block"; 2074 if (desc) desc.style.display = "block"; 2075 element.classList.add("event-past-expanded"); 2076 } else { 2077 // Collapse 2078 meta.style.display = "none"; 2079 if (desc) desc.style.display = "none"; 2080 element.classList.remove("event-past-expanded"); 2081 } 2082}; 2083 2084// Filter calendar by namespace when clicking namespace badge (guarded) 2085if (!window._calendarClickDelegationInit) { 2086 window._calendarClickDelegationInit = true; 2087 document.addEventListener('click', function(e) { 2088 if (e.target.classList.contains('event-namespace-badge')) { 2089 const namespace = e.target.textContent; 2090 const calendar = e.target.closest('.calendar-compact-container'); 2091 2092 if (!calendar) return; 2093 2094 const calId = calendar.id; 2095 2096 // Use AJAX reload to filter both calendar grid and event list 2097 filterCalendarByNamespace(calId, namespace); 2098 } 2099 }); 2100} // end click delegation guard 2101 2102// Update the displayed filtered namespace in event list header 2103// Legacy badge removed - namespace filtering still works but badge no longer shown 2104window.updateFilteredNamespaceDisplay = function(calId, namespace) { 2105 const calendar = document.getElementById(calId); 2106 if (!calendar) return; 2107 2108 const headerContent = calendar.querySelector('.event-list-header-content'); 2109 if (!headerContent) return; 2110 2111 // Remove any existing filter badge (cleanup) 2112 let filterBadge = headerContent.querySelector('.namespace-filter-badge'); 2113 if (filterBadge) { 2114 filterBadge.remove(); 2115 } 2116}; 2117 2118// Clear namespace filter 2119window.clearNamespaceFilter = function(calId) { 2120 2121 const container = document.getElementById(calId); 2122 if (!container) { 2123 console.error('Calendar container not found:', calId); 2124 return; 2125 } 2126 2127 // Immediately hide/remove the filter badge 2128 const filterBadge = container.querySelector('.calendar-namespace-filter'); 2129 if (filterBadge) { 2130 filterBadge.style.display = 'none'; 2131 filterBadge.remove(); 2132 } 2133 2134 // Get current year and month 2135 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 2136 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 2137 2138 // Get original namespace (what the calendar was initialized with) 2139 const originalNamespace = container.dataset.originalNamespace || ''; 2140 2141 // Also check for sidebar widget 2142 const sidebarContainer = document.getElementById('sidebar-widget-' + calId); 2143 if (sidebarContainer) { 2144 // For sidebar widget, just reload the page without namespace filter 2145 // Remove the namespace from the URL and reload 2146 const url = new URL(window.location.href); 2147 url.searchParams.delete('namespace'); 2148 window.location.href = url.toString(); 2149 return; 2150 } 2151 2152 // For regular calendar, reload calendar with original namespace 2153 navCalendar(calId, year, month, originalNamespace); 2154}; 2155 2156window.clearNamespaceFilterPanel = function(calId) { 2157 2158 const container = document.getElementById(calId); 2159 if (!container) { 2160 console.error('Event panel container not found:', calId); 2161 return; 2162 } 2163 2164 // Get current year and month from URL params or container 2165 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 2166 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 2167 2168 // Get original namespace (what the panel was initialized with) 2169 const originalNamespace = container.dataset.originalNamespace || ''; 2170 2171 2172 // Reload event panel with original namespace 2173 navEventPanel(calId, year, month, originalNamespace); 2174}; 2175 2176// Color picker functions 2177window.updateCustomColorPicker = function(calId) { 2178 const select = document.getElementById('event-color-' + calId); 2179 const picker = document.getElementById('event-color-custom-' + calId); 2180 2181 if (select.value === 'custom') { 2182 // Show color picker 2183 picker.style.display = 'inline-block'; 2184 picker.click(); // Open color picker 2185 } else { 2186 // Hide color picker and sync value 2187 picker.style.display = 'none'; 2188 picker.value = select.value; 2189 } 2190}; 2191 2192function updateColorFromPicker(calId) { 2193 const select = document.getElementById('event-color-' + calId); 2194 const picker = document.getElementById('event-color-custom-' + calId); 2195 2196 // Set select to custom and update its underlying value 2197 select.value = 'custom'; 2198 // Store the actual color value in a data attribute 2199 select.dataset.customColor = picker.value; 2200} 2201 2202// Toggle past events visibility 2203window.togglePastEvents = function(calId) { 2204 const content = document.getElementById('past-events-' + calId); 2205 const arrow = document.getElementById('past-arrow-' + calId); 2206 2207 if (!content || !arrow) { 2208 console.error('Past events elements not found for:', calId); 2209 return; 2210 } 2211 2212 // Check computed style instead of inline style 2213 const isHidden = window.getComputedStyle(content).display === 'none'; 2214 2215 if (isHidden) { 2216 content.style.display = 'block'; 2217 arrow.textContent = '▼'; 2218 } else { 2219 content.style.display = 'none'; 2220 arrow.textContent = '▶'; 2221 } 2222}; 2223 2224// Fuzzy match scoring function 2225window.fuzzyMatch = function(pattern, str) { 2226 pattern = pattern.toLowerCase(); 2227 str = str.toLowerCase(); 2228 2229 let patternIdx = 0; 2230 let score = 0; 2231 let consecutiveMatches = 0; 2232 2233 for (let i = 0; i < str.length; i++) { 2234 if (patternIdx < pattern.length && str[i] === pattern[patternIdx]) { 2235 score += 1 + consecutiveMatches; 2236 consecutiveMatches++; 2237 patternIdx++; 2238 } else { 2239 consecutiveMatches = 0; 2240 } 2241 } 2242 2243 // Return null if not all characters matched 2244 if (patternIdx !== pattern.length) { 2245 return null; 2246 } 2247 2248 // Bonus for exact match 2249 if (str === pattern) { 2250 score += 100; 2251 } 2252 2253 // Bonus for starts with 2254 if (str.startsWith(pattern)) { 2255 score += 50; 2256 } 2257 2258 return score; 2259}; 2260 2261// Initialize namespace search for a calendar 2262window.initNamespaceSearch = function(calId) { 2263 const searchInput = document.getElementById('event-namespace-search-' + calId); 2264 const hiddenInput = document.getElementById('event-namespace-' + calId); 2265 const dropdown = document.getElementById('event-namespace-dropdown-' + calId); 2266 const dataElement = document.getElementById('namespaces-data-' + calId); 2267 2268 if (!searchInput || !hiddenInput || !dropdown || !dataElement) { 2269 return; // Elements not found 2270 } 2271 2272 let namespaces = []; 2273 try { 2274 namespaces = JSON.parse(dataElement.textContent); 2275 } catch (e) { 2276 console.error('Failed to parse namespaces data:', e); 2277 return; 2278 } 2279 2280 let selectedIndex = -1; 2281 2282 // Filter and show dropdown 2283 function filterNamespaces(query) { 2284 if (!query || query.trim() === '') { 2285 // Show all namespaces when empty 2286 hiddenInput.value = ''; 2287 const results = namespaces.slice(0, 20); // Limit to 20 2288 showDropdown(results); 2289 return; 2290 } 2291 2292 // Fuzzy match and score 2293 const matches = []; 2294 for (let i = 0; i < namespaces.length; i++) { 2295 const score = fuzzyMatch(query, namespaces[i]); 2296 if (score !== null) { 2297 matches.push({ namespace: namespaces[i], score: score }); 2298 } 2299 } 2300 2301 // Sort by score (descending) 2302 matches.sort((a, b) => b.score - a.score); 2303 2304 // Take top 20 results 2305 const results = matches.slice(0, 20).map(m => m.namespace); 2306 showDropdown(results); 2307 } 2308 2309 function showDropdown(results) { 2310 dropdown.innerHTML = ''; 2311 selectedIndex = -1; 2312 2313 if (results.length === 0) { 2314 dropdown.style.display = 'none'; 2315 return; 2316 } 2317 2318 // Add (default) option 2319 const defaultOption = document.createElement('div'); 2320 defaultOption.className = 'namespace-option'; 2321 defaultOption.textContent = '(default)'; 2322 defaultOption.dataset.value = ''; 2323 dropdown.appendChild(defaultOption); 2324 2325 results.forEach(ns => { 2326 const option = document.createElement('div'); 2327 option.className = 'namespace-option'; 2328 option.textContent = ns; 2329 option.dataset.value = ns; 2330 dropdown.appendChild(option); 2331 }); 2332 2333 dropdown.style.display = 'block'; 2334 } 2335 2336 function hideDropdown() { 2337 dropdown.style.display = 'none'; 2338 selectedIndex = -1; 2339 } 2340 2341 function selectOption(namespace) { 2342 hiddenInput.value = namespace; 2343 searchInput.value = namespace || '(default)'; 2344 hideDropdown(); 2345 } 2346 2347 // Event listeners 2348 searchInput.addEventListener('input', function(e) { 2349 filterNamespaces(e.target.value); 2350 }); 2351 2352 searchInput.addEventListener('focus', function(e) { 2353 filterNamespaces(e.target.value); 2354 }); 2355 2356 searchInput.addEventListener('blur', function(e) { 2357 // Delay to allow click on dropdown 2358 setTimeout(hideDropdown, 200); 2359 }); 2360 2361 searchInput.addEventListener('keydown', function(e) { 2362 const options = dropdown.querySelectorAll('.namespace-option'); 2363 2364 if (e.key === 'ArrowDown') { 2365 e.preventDefault(); 2366 selectedIndex = Math.min(selectedIndex + 1, options.length - 1); 2367 updateSelection(options); 2368 } else if (e.key === 'ArrowUp') { 2369 e.preventDefault(); 2370 selectedIndex = Math.max(selectedIndex - 1, -1); 2371 updateSelection(options); 2372 } else if (e.key === 'Enter') { 2373 e.preventDefault(); 2374 if (selectedIndex >= 0 && options[selectedIndex]) { 2375 selectOption(options[selectedIndex].dataset.value); 2376 } 2377 } else if (e.key === 'Escape') { 2378 hideDropdown(); 2379 } 2380 }); 2381 2382 function updateSelection(options) { 2383 options.forEach((opt, idx) => { 2384 if (idx === selectedIndex) { 2385 opt.classList.add('selected'); 2386 opt.scrollIntoView({ block: 'nearest' }); 2387 } else { 2388 opt.classList.remove('selected'); 2389 } 2390 }); 2391 } 2392 2393 // Click on dropdown option 2394 dropdown.addEventListener('mousedown', function(e) { 2395 if (e.target.classList.contains('namespace-option')) { 2396 selectOption(e.target.dataset.value); 2397 } 2398 }); 2399}; 2400 2401// Update end time options based on start time selection 2402window.updateEndTimeOptions = function(calId) { 2403 const startTimeSelect = document.getElementById('event-time-' + calId); 2404 const endTimeSelect = document.getElementById('event-end-time-' + calId); 2405 2406 if (!startTimeSelect || !endTimeSelect) return; 2407 2408 const startTime = startTimeSelect.value; 2409 2410 // If start time is empty (all day), disable end time 2411 if (!startTime) { 2412 endTimeSelect.disabled = true; 2413 endTimeSelect.value = ''; 2414 return; 2415 } 2416 2417 // Enable end time select 2418 endTimeSelect.disabled = false; 2419 2420 // Convert start time to minutes 2421 const startMinutes = timeToMinutes(startTime); 2422 2423 // Get current end time value (to preserve if valid) 2424 const currentEndTime = endTimeSelect.value; 2425 const currentEndMinutes = currentEndTime ? timeToMinutes(currentEndTime) : 0; 2426 2427 // Filter options - show only times after start time 2428 const options = endTimeSelect.options; 2429 let firstValidOption = null; 2430 let currentStillValid = false; 2431 2432 for (let i = 0; i < options.length; i++) { 2433 const option = options[i]; 2434 const optionValue = option.value; 2435 2436 if (optionValue === '') { 2437 // Keep "Same as start" option visible 2438 option.style.display = ''; 2439 continue; 2440 } 2441 2442 const optionMinutes = timeToMinutes(optionValue); 2443 2444 if (optionMinutes > startMinutes) { 2445 // Show options after start time 2446 option.style.display = ''; 2447 if (!firstValidOption) { 2448 firstValidOption = optionValue; 2449 } 2450 if (optionValue === currentEndTime) { 2451 currentStillValid = true; 2452 } 2453 } else { 2454 // Hide options before or equal to start time 2455 option.style.display = 'none'; 2456 } 2457 } 2458 2459 // If current end time is now invalid, set a new one 2460 if (!currentStillValid || currentEndMinutes <= startMinutes) { 2461 // Try to set to 1 hour after start 2462 const [startHour, startMinute] = startTime.split(':').map(Number); 2463 let endHour = startHour + 1; 2464 let endMinute = startMinute; 2465 2466 if (endHour >= 24) { 2467 endHour = 23; 2468 endMinute = 45; 2469 } 2470 2471 const suggestedEndTime = String(endHour).padStart(2, '0') + ':' + String(endMinute).padStart(2, '0'); 2472 2473 // Check if suggested time is in the list 2474 const suggestedExists = Array.from(options).some(opt => opt.value === suggestedEndTime); 2475 2476 if (suggestedExists) { 2477 endTimeSelect.value = suggestedEndTime; 2478 } else if (firstValidOption) { 2479 // Use first valid option 2480 endTimeSelect.value = firstValidOption; 2481 } else { 2482 // No valid options (shouldn't happen, but just in case) 2483 endTimeSelect.value = ''; 2484 } 2485 } 2486}; 2487 2488// Check for time conflicts between events on the same date 2489window.checkTimeConflicts = function(events, currentEventId) { 2490 const conflicts = []; 2491 2492 // Group events by date 2493 const eventsByDate = {}; 2494 for (const [date, dateEvents] of Object.entries(events)) { 2495 if (!Array.isArray(dateEvents)) continue; 2496 2497 dateEvents.forEach(evt => { 2498 if (!evt.time || evt.id === currentEventId) return; // Skip all-day events and current event 2499 2500 if (!eventsByDate[date]) eventsByDate[date] = []; 2501 eventsByDate[date].push(evt); 2502 }); 2503 } 2504 2505 // Check for overlaps on each date 2506 for (const [date, dateEvents] of Object.entries(eventsByDate)) { 2507 for (let i = 0; i < dateEvents.length; i++) { 2508 for (let j = i + 1; j < dateEvents.length; j++) { 2509 const evt1 = dateEvents[i]; 2510 const evt2 = dateEvents[j]; 2511 2512 if (eventsOverlap(evt1, evt2)) { 2513 // Mark both events as conflicting 2514 if (!evt1.hasConflict) evt1.hasConflict = true; 2515 if (!evt2.hasConflict) evt2.hasConflict = true; 2516 2517 // Store conflict info 2518 if (!evt1.conflictsWith) evt1.conflictsWith = []; 2519 if (!evt2.conflictsWith) evt2.conflictsWith = []; 2520 2521 evt1.conflictsWith.push({id: evt2.id, title: evt2.title, time: evt2.time, endTime: evt2.endTime}); 2522 evt2.conflictsWith.push({id: evt1.id, title: evt1.title, time: evt1.time, endTime: evt1.endTime}); 2523 } 2524 } 2525 } 2526 } 2527 2528 return events; 2529}; 2530 2531// Check if two events overlap in time 2532function eventsOverlap(evt1, evt2) { 2533 if (!evt1.time || !evt2.time) return false; // All-day events don't conflict 2534 2535 const start1 = evt1.time; 2536 const end1 = evt1.endTime || evt1.time; // If no end time, treat as same as start 2537 2538 const start2 = evt2.time; 2539 const end2 = evt2.endTime || evt2.time; 2540 2541 // Convert to minutes for easier comparison 2542 const start1Mins = timeToMinutes(start1); 2543 const end1Mins = timeToMinutes(end1); 2544 const start2Mins = timeToMinutes(start2); 2545 const end2Mins = timeToMinutes(end2); 2546 2547 // Check for overlap 2548 // Events overlap if: start1 < end2 AND start2 < end1 2549 return start1Mins < end2Mins && start2Mins < end1Mins; 2550} 2551 2552// Convert HH:MM time to minutes since midnight 2553function timeToMinutes(timeStr) { 2554 const [hours, minutes] = timeStr.split(':').map(Number); 2555 return hours * 60 + minutes; 2556} 2557 2558// Format time range for display 2559window.formatTimeRange = function(startTime, endTime) { 2560 if (!startTime) return ''; 2561 2562 const formatTime = (timeStr) => { 2563 const [hour24, minute] = timeStr.split(':').map(Number); 2564 const hour12 = hour24 === 0 ? 12 : (hour24 > 12 ? hour24 - 12 : hour24); 2565 const ampm = hour24 < 12 ? 'AM' : 'PM'; 2566 return hour12 + ':' + String(minute).padStart(2, '0') + ' ' + ampm; 2567 }; 2568 2569 if (!endTime || endTime === startTime) { 2570 return formatTime(startTime); 2571 } 2572 2573 return formatTime(startTime) + ' - ' + formatTime(endTime); 2574}; 2575 2576// Track last known mouse position for tooltip positioning fallback 2577var _lastMouseX = 0, _lastMouseY = 0; 2578document.addEventListener('mousemove', function(e) { 2579 _lastMouseX = e.clientX; 2580 _lastMouseY = e.clientY; 2581}); 2582 2583// Show custom conflict tooltip 2584window.showConflictTooltip = function(badgeElement) { 2585 // Remove any existing tooltip 2586 hideConflictTooltip(); 2587 2588 // Get conflict data (base64-encoded JSON to avoid attribute quote issues) 2589 const conflictsRaw = badgeElement.getAttribute('data-conflicts'); 2590 if (!conflictsRaw) return; 2591 2592 let conflicts; 2593 try { 2594 conflicts = JSON.parse(decodeURIComponent(escape(atob(conflictsRaw)))); 2595 } catch (e) { 2596 // Fallback: try parsing as plain JSON (for PHP-rendered badges) 2597 try { 2598 conflicts = JSON.parse(conflictsRaw); 2599 } catch (e2) { 2600 console.error('Failed to parse conflicts:', e2); 2601 return; 2602 } 2603 } 2604 2605 // Get theme from the calendar container via CSS variables 2606 // Try closest ancestor first, then fall back to any calendar on the page 2607 let containerEl = badgeElement.closest('[id^="cal_"], [id^="panel_"], [id^="sidebar-widget-"], .calendar-compact-container, .event-panel-standalone'); 2608 if (!containerEl) { 2609 // Badge might be inside a day popup (appended to body) - find any calendar container 2610 containerEl = document.querySelector('.calendar-compact-container, .event-panel-standalone, [id^="sidebar-widget-"]'); 2611 } 2612 const cs = containerEl ? getComputedStyle(containerEl) : null; 2613 2614 const bg = cs ? cs.getPropertyValue('--background-site').trim() || '#242424' : '#242424'; 2615 const border = cs ? cs.getPropertyValue('--border-main').trim() || '#00cc07' : '#00cc07'; 2616 const textPrimary = cs ? cs.getPropertyValue('--text-primary').trim() || '#00cc07' : '#00cc07'; 2617 const textDim = cs ? cs.getPropertyValue('--text-dim').trim() || '#00aa00' : '#00aa00'; 2618 const shadow = cs ? cs.getPropertyValue('--shadow-color').trim() || 'rgba(0, 204, 7, 0.3)' : 'rgba(0, 204, 7, 0.3)'; 2619 2620 // Create tooltip 2621 const tooltip = document.createElement('div'); 2622 tooltip.id = 'conflict-tooltip'; 2623 tooltip.className = 'conflict-tooltip'; 2624 2625 // Apply theme styles 2626 tooltip.style.background = bg; 2627 tooltip.style.borderColor = border; 2628 tooltip.style.color = textPrimary; 2629 tooltip.style.boxShadow = '0 4px 12px ' + shadow; 2630 2631 // Build content with themed colors 2632 let html = '<div class="conflict-tooltip-header" style="background: ' + border + '; color: ' + bg + '; border-bottom: 1px solid ' + border + ';">⚠️ Time Conflicts</div>'; 2633 html += '<div class="conflict-tooltip-body">'; 2634 conflicts.forEach(conflict => { 2635 html += '<div class="conflict-item" style="color: ' + textDim + '; border-bottom-color: ' + border + ';">• ' + escapeHtml(conflict) + '</div>'; 2636 }); 2637 html += '</div>'; 2638 2639 tooltip.innerHTML = html; 2640 document.body.appendChild(tooltip); 2641 2642 // Position tooltip 2643 const rect = badgeElement.getBoundingClientRect(); 2644 const tooltipRect = tooltip.getBoundingClientRect(); 2645 2646 // Position above the badge, centered 2647 let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); 2648 let top = rect.top - tooltipRect.height - 8; 2649 2650 // Keep tooltip within viewport 2651 if (left < 10) left = 10; 2652 if (left + tooltipRect.width > window.innerWidth - 10) { 2653 left = window.innerWidth - tooltipRect.width - 10; 2654 } 2655 if (top < 10) { 2656 // If not enough room above, show below 2657 top = rect.bottom + 8; 2658 } 2659 2660 tooltip.style.left = left + 'px'; 2661 tooltip.style.top = top + 'px'; 2662 tooltip.style.opacity = '1'; 2663}; 2664 2665// Hide conflict tooltip 2666window.hideConflictTooltip = function() { 2667 const tooltip = document.getElementById('conflict-tooltip'); 2668 if (tooltip) { 2669 tooltip.remove(); 2670 } 2671}; 2672 2673// Fuzzy search helper for event filtering - normalizes text for matching 2674function eventSearchNormalize(text) { 2675 if (typeof text !== 'string') { 2676 console.log('[eventSearchNormalize] WARNING: text is not a string:', typeof text, text); 2677 return ''; 2678 } 2679 return text 2680 .toLowerCase() 2681 .trim() 2682 // Remove common punctuation that might differ 2683 .replace(/[''\u2018\u2019]/g, '') // Remove apostrophes/quotes 2684 .replace(/["""\u201C\u201D]/g, '') // Remove smart quotes 2685 .replace(/[-–—]/g, ' ') // Dashes to spaces 2686 .replace(/[.,!?;:]/g, '') // Remove punctuation 2687 .replace(/\s+/g, ' ') // Normalize whitespace 2688 .trim(); 2689} 2690 2691// Check if search term matches text for event filtering 2692function eventSearchMatch(text, searchTerm) { 2693 const normalizedText = eventSearchNormalize(text); 2694 const normalizedSearch = eventSearchNormalize(searchTerm); 2695 2696 // Direct match after normalization 2697 if (normalizedText.includes(normalizedSearch)) { 2698 return true; 2699 } 2700 2701 // Split search into words and check if all words are present 2702 const searchWords = normalizedSearch.split(' ').filter(w => w.length > 0); 2703 if (searchWords.length > 1) { 2704 return searchWords.every(word => normalizedText.includes(word)); 2705 } 2706 2707 return false; 2708} 2709 2710// Filter events by search term 2711window.filterEvents = function(calId, searchTerm) { 2712 const eventList = document.getElementById('eventlist-' + calId); 2713 const searchClear = document.getElementById('search-clear-' + calId); 2714 const searchMode = document.getElementById('search-mode-' + calId); 2715 2716 if (!eventList) return; 2717 2718 // Check if we're in "all dates" mode 2719 const isAllDatesMode = searchMode && searchMode.classList.contains('all-dates'); 2720 2721 // Show/hide clear button 2722 if (searchClear) { 2723 searchClear.style.display = searchTerm ? 'block' : 'none'; 2724 } 2725 2726 searchTerm = searchTerm.trim(); 2727 2728 // If all-dates mode and we have a search term, do AJAX search 2729 if (isAllDatesMode && searchTerm.length >= 2) { 2730 searchAllDates(calId, searchTerm); 2731 return; 2732 } 2733 2734 // If all-dates mode but search cleared, restore normal view 2735 if (isAllDatesMode && !searchTerm) { 2736 // Remove search results container if exists 2737 const resultsContainer = eventList.querySelector('.all-dates-results'); 2738 if (resultsContainer) { 2739 resultsContainer.remove(); 2740 } 2741 // Show normal event items 2742 eventList.querySelectorAll('.event-compact-item').forEach(item => { 2743 item.style.display = ''; 2744 }); 2745 // Show past events toggle if it exists 2746 const pastToggle = eventList.querySelector('.past-events-toggle'); 2747 if (pastToggle) pastToggle.style.display = ''; 2748 } 2749 2750 // Get all event items 2751 const eventItems = eventList.querySelectorAll('.event-compact-item'); 2752 let visibleCount = 0; 2753 let hiddenPastCount = 0; 2754 2755 eventItems.forEach(item => { 2756 const title = item.querySelector('.event-title-compact'); 2757 const description = item.querySelector('.event-desc-compact'); 2758 const dateTime = item.querySelector('.event-date-time'); 2759 2760 // Build searchable text 2761 let searchableText = ''; 2762 if (title) searchableText += title.textContent + ' '; 2763 if (description) searchableText += description.textContent + ' '; 2764 if (dateTime) searchableText += dateTime.textContent + ' '; 2765 2766 // Check if matches search using fuzzy matching 2767 const matches = !searchTerm || eventSearchMatch(searchableText, searchTerm); 2768 2769 if (matches) { 2770 item.style.display = ''; 2771 visibleCount++; 2772 } else { 2773 item.style.display = 'none'; 2774 // Check if this is a past event 2775 if (item.classList.contains('event-past') || item.classList.contains('event-completed')) { 2776 hiddenPastCount++; 2777 } 2778 } 2779 }); 2780 2781 // Update past events toggle if it exists 2782 const pastToggle = eventList.querySelector('.past-events-toggle'); 2783 const pastLabel = eventList.querySelector('.past-events-label'); 2784 const pastContent = document.getElementById('past-events-' + calId); 2785 2786 if (pastToggle && pastLabel && pastContent) { 2787 const visiblePastEvents = pastContent.querySelectorAll('.event-compact-item:not([style*="display: none"])'); 2788 const totalPastVisible = visiblePastEvents.length; 2789 2790 if (totalPastVisible > 0) { 2791 pastLabel.textContent = `Past Events (${totalPastVisible})`; 2792 pastToggle.style.display = ''; 2793 } else { 2794 pastToggle.style.display = 'none'; 2795 } 2796 } 2797 2798 // Show "no results" message if nothing visible (only for month mode, not all-dates mode) 2799 let noResultsMsg = eventList.querySelector('.no-search-results'); 2800 if (visibleCount === 0 && searchTerm && !isAllDatesMode) { 2801 if (!noResultsMsg) { 2802 noResultsMsg = document.createElement('p'); 2803 noResultsMsg.className = 'no-search-results no-events-msg'; 2804 noResultsMsg.textContent = 'No events match your search'; 2805 eventList.appendChild(noResultsMsg); 2806 } 2807 noResultsMsg.style.display = 'block'; 2808 } else if (noResultsMsg) { 2809 noResultsMsg.style.display = 'none'; 2810 } 2811}; 2812 2813// Toggle search mode between "this month" and "all dates" 2814window.toggleSearchMode = function(calId, namespace) { 2815 const searchMode = document.getElementById('search-mode-' + calId); 2816 const searchInput = document.getElementById('event-search-' + calId); 2817 2818 if (!searchMode) return; 2819 2820 const isAllDates = searchMode.classList.toggle('all-dates'); 2821 2822 // Update button icon and title 2823 if (isAllDates) { 2824 searchMode.innerHTML = ''; 2825 searchMode.title = 'Searching all dates'; 2826 if (searchInput) { 2827 searchInput.placeholder = 'Search all dates...'; 2828 } 2829 } else { 2830 searchMode.innerHTML = ''; 2831 searchMode.title = 'Search this month only'; 2832 if (searchInput) { 2833 searchInput.placeholder = searchInput.classList.contains('panel-search-input') ? 'Search this month...' : ' Search...'; 2834 } 2835 } 2836 2837 // Re-run search with current term 2838 if (searchInput && searchInput.value) { 2839 filterEvents(calId, searchInput.value); 2840 } else { 2841 // Clear any all-dates results 2842 const eventList = document.getElementById('eventlist-' + calId); 2843 if (eventList) { 2844 const resultsContainer = eventList.querySelector('.all-dates-results'); 2845 if (resultsContainer) { 2846 resultsContainer.remove(); 2847 } 2848 // Show normal event items 2849 eventList.querySelectorAll('.event-compact-item').forEach(item => { 2850 item.style.display = ''; 2851 }); 2852 const pastToggle = eventList.querySelector('.past-events-toggle'); 2853 if (pastToggle) pastToggle.style.display = ''; 2854 } 2855 } 2856}; 2857 2858// Search all dates via AJAX 2859window.searchAllDates = function(calId, searchTerm) { 2860 const eventList = document.getElementById('eventlist-' + calId); 2861 if (!eventList) return; 2862 2863 // Get namespace from container 2864 const container = document.getElementById(calId); 2865 const namespace = container ? (container.dataset.namespace || '') : ''; 2866 2867 // Hide normal event items 2868 eventList.querySelectorAll('.event-compact-item').forEach(item => { 2869 item.style.display = 'none'; 2870 }); 2871 const pastToggle = eventList.querySelector('.past-events-toggle'); 2872 if (pastToggle) pastToggle.style.display = 'none'; 2873 2874 // Remove old results container 2875 let resultsContainer = eventList.querySelector('.all-dates-results'); 2876 if (resultsContainer) { 2877 resultsContainer.remove(); 2878 } 2879 2880 // Create new results container 2881 resultsContainer = document.createElement('div'); 2882 resultsContainer.className = 'all-dates-results'; 2883 resultsContainer.innerHTML = '<p class="search-loading" style="text-align:center; padding:20px; color:var(--text-dim);"> Searching all dates...</p>'; 2884 eventList.appendChild(resultsContainer); 2885 2886 // Make AJAX request 2887 const params = new URLSearchParams({ 2888 call: 'plugin_calendar', 2889 action: 'search_all', 2890 search: searchTerm, 2891 namespace: namespace, 2892 _: new Date().getTime() 2893 }); 2894 2895 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 2896 method: 'POST', 2897 headers: { 2898 'Content-Type': 'application/x-www-form-urlencoded' 2899 }, 2900 body: params.toString() 2901 }) 2902 .then(r => r.json()) 2903 .then(data => { 2904 if (data.success && data.results) { 2905 if (data.results.length === 0) { 2906 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>'; 2907 } else { 2908 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>'; 2909 2910 data.results.forEach(event => { 2911 const dateObj = new Date(event.date + 'T00:00:00'); 2912 const dateDisplay = dateObj.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); 2913 const color = event.color || 'var(--text-bright, #00cc07)'; 2914 2915 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 + '\')">'; 2916 html += '<div style="width:3px; background:' + color + '; border-radius:1px; flex-shrink:0;"></div>'; 2917 html += '<div style="flex:1; min-width:0;">'; 2918 html += '<div class="event-title-compact" style="font-weight:600; color:var(--text-primary); font-size:11px;">' + escapeHtml(event.title) + '</div>'; 2919 html += '<div class="event-date-time" style="font-size:10px; color:var(--text-dim);">' + dateDisplay; 2920 if (event.time) { 2921 html += ' • ' + formatTimeRange(event.time, event.endTime); 2922 } 2923 html += '</div>'; 2924 if (event.namespace) { 2925 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>'; 2926 } 2927 html += '</div></div>'; 2928 }); 2929 2930 resultsContainer.innerHTML = html; 2931 } 2932 } else { 2933 resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim);">Search failed. Please try again.</p>'; 2934 } 2935 }) 2936 .catch(err => { 2937 console.error('Search error:', err); 2938 resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim);">Search failed. Please try again.</p>'; 2939 }); 2940}; 2941 2942// Jump to a specific date (used by search results) 2943window.jumpToDate = function(calId, date, namespace) { 2944 const parts = date.split('-'); 2945 const year = parseInt(parts[0]); 2946 const month = parseInt(parts[1]); 2947 2948 // Get container to check current month 2949 const container = document.getElementById(calId); 2950 const currentYear = container ? parseInt(container.dataset.year) : year; 2951 const currentMonth = container ? parseInt(container.dataset.month) : month; 2952 2953 // Get search elements 2954 const searchInput = document.getElementById('event-search-' + calId); 2955 const searchMode = document.getElementById('search-mode-' + calId); 2956 const searchClear = document.getElementById('search-clear-' + calId); 2957 const eventList = document.getElementById('eventlist-' + calId); 2958 2959 // Remove the all-dates results container 2960 if (eventList) { 2961 const resultsContainer = eventList.querySelector('.all-dates-results'); 2962 if (resultsContainer) { 2963 resultsContainer.remove(); 2964 } 2965 // Show normal event items again 2966 eventList.querySelectorAll('.event-compact-item').forEach(item => { 2967 item.style.display = ''; 2968 }); 2969 const pastToggle = eventList.querySelector('.past-events-toggle'); 2970 if (pastToggle) pastToggle.style.display = ''; 2971 2972 // Hide any no-results message 2973 const noResults = eventList.querySelector('.no-search-results'); 2974 if (noResults) noResults.style.display = 'none'; 2975 } 2976 2977 // Clear search input 2978 if (searchInput) { 2979 searchInput.value = ''; 2980 } 2981 2982 // Hide clear button 2983 if (searchClear) { 2984 searchClear.style.display = 'none'; 2985 } 2986 2987 // Switch back to month mode 2988 if (searchMode && searchMode.classList.contains('all-dates')) { 2989 searchMode.classList.remove('all-dates'); 2990 searchMode.innerHTML = ''; 2991 searchMode.title = 'Search this month only'; 2992 if (searchInput) { 2993 searchInput.placeholder = searchInput.classList.contains('panel-search-input') ? 'Search this month...' : ' Search...'; 2994 } 2995 } 2996 2997 // Check if we need to navigate to a different month 2998 if (year !== currentYear || month !== currentMonth) { 2999 // Navigate to the target month, then show popup 3000 navCalendar(calId, year, month, namespace); 3001 3002 // After navigation completes, show the day popup 3003 setTimeout(() => { 3004 showDayPopup(calId, date, namespace); 3005 }, 400); 3006 } else { 3007 // Same month - just show the popup 3008 showDayPopup(calId, date, namespace); 3009 } 3010}; 3011 3012// Clear event search 3013window.clearEventSearch = function(calId) { 3014 const searchInput = document.getElementById('event-search-' + calId); 3015 if (searchInput) { 3016 searchInput.value = ''; 3017 filterEvents(calId, ''); 3018 searchInput.focus(); 3019 } 3020}; 3021 3022// ============================================ 3023// PINK THEME - GLOWING PARTICLE EFFECTS 3024// ============================================ 3025 3026// Create glowing pink particle effects for pink theme 3027(function() { 3028 let pinkThemeActive = false; 3029 let trailTimer = null; 3030 let pixelTimer = null; 3031 3032 // Check if pink theme is active 3033 function checkPinkTheme() { 3034 const pinkCalendars = document.querySelectorAll('.calendar-theme-pink'); 3035 pinkThemeActive = pinkCalendars.length > 0; 3036 return pinkThemeActive; 3037 } 3038 3039 // Create trail particle 3040 function createTrailParticle(clientX, clientY) { 3041 if (!pinkThemeActive) return; 3042 3043 const trail = document.createElement('div'); 3044 trail.className = 'pink-cursor-trail'; 3045 trail.style.left = clientX + 'px'; 3046 trail.style.top = clientY + 'px'; 3047 trail.style.animation = 'cursor-trail-fade 0.5s ease-out forwards'; 3048 3049 document.body.appendChild(trail); 3050 3051 setTimeout(function() { 3052 trail.remove(); 3053 }, 500); 3054 } 3055 3056 // Create pixel sparkles 3057 function createPixelSparkles(clientX, clientY) { 3058 if (!pinkThemeActive || pixelTimer) return; 3059 3060 const pixelCount = 3 + Math.floor(Math.random() * 4); // 3-6 pixels 3061 3062 for (let i = 0; i < pixelCount; i++) { 3063 const pixel = document.createElement('div'); 3064 pixel.className = 'pink-pixel-sparkle'; 3065 3066 // Random offset from cursor 3067 const offsetX = (Math.random() - 0.5) * 30; 3068 const offsetY = (Math.random() - 0.5) * 30; 3069 3070 pixel.style.left = (clientX + offsetX) + 'px'; 3071 pixel.style.top = (clientY + offsetY) + 'px'; 3072 3073 // Random color - bright neon pinks and whites 3074 const colors = ['#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1']; 3075 const color = colors[Math.floor(Math.random() * colors.length)]; 3076 pixel.style.background = color; 3077 pixel.style.boxShadow = '0 0 2px ' + color + ', 0 0 4px ' + color + ', 0 0 6px #fff'; 3078 3079 // Random animation 3080 if (Math.random() > 0.5) { 3081 pixel.style.animation = 'pixel-twinkle 0.6s ease-out forwards'; 3082 } else { 3083 pixel.style.animation = 'pixel-float-away 0.8s ease-out forwards'; 3084 } 3085 3086 document.body.appendChild(pixel); 3087 3088 setTimeout(function() { 3089 pixel.remove(); 3090 }, 800); 3091 } 3092 3093 pixelTimer = setTimeout(function() { 3094 pixelTimer = null; 3095 }, 40); 3096 } 3097 3098 // Create explosion 3099 function createExplosion(clientX, clientY) { 3100 if (!pinkThemeActive) return; 3101 3102 const particleCount = 25; 3103 const colors = ['#ff1493', '#ff69b4', '#ff85c1', '#ffc0cb', '#fff']; 3104 3105 // Add hearts to explosion (8-12 hearts) 3106 const heartCount = 8 + Math.floor(Math.random() * 5); 3107 for (let i = 0; i < heartCount; i++) { 3108 const heart = document.createElement('div'); 3109 heart.textContent = ''; 3110 heart.style.position = 'fixed'; 3111 heart.style.left = clientX + 'px'; 3112 heart.style.top = clientY + 'px'; 3113 heart.style.pointerEvents = 'none'; 3114 heart.style.zIndex = '9999999'; 3115 heart.style.fontSize = (12 + Math.random() * 16) + 'px'; 3116 3117 // Random direction 3118 const angle = Math.random() * Math.PI * 2; 3119 const velocity = 60 + Math.random() * 80; 3120 const tx = Math.cos(angle) * velocity; 3121 const ty = Math.sin(angle) * velocity; 3122 3123 heart.style.setProperty('--tx', tx + 'px'); 3124 heart.style.setProperty('--ty', ty + 'px'); 3125 3126 const duration = 0.8 + Math.random() * 0.4; 3127 heart.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 3128 3129 document.body.appendChild(heart); 3130 3131 setTimeout(function() { 3132 heart.remove(); 3133 }, duration * 1000); 3134 } 3135 3136 // Main explosion particles 3137 for (let i = 0; i < particleCount; i++) { 3138 const particle = document.createElement('div'); 3139 particle.className = 'pink-particle'; 3140 3141 const color = colors[Math.floor(Math.random() * colors.length)]; 3142 particle.style.background = 'radial-gradient(circle, ' + color + ', transparent)'; 3143 particle.style.boxShadow = '0 0 10px ' + color + ', 0 0 20px ' + color; 3144 3145 particle.style.left = clientX + 'px'; 3146 particle.style.top = clientY + 'px'; 3147 3148 const angle = (Math.PI * 2 * i) / particleCount; 3149 const velocity = 50 + Math.random() * 100; 3150 const tx = Math.cos(angle) * velocity; 3151 const ty = Math.sin(angle) * velocity; 3152 3153 particle.style.setProperty('--tx', tx + 'px'); 3154 particle.style.setProperty('--ty', ty + 'px'); 3155 3156 const size = 4 + Math.random() * 6; 3157 particle.style.width = size + 'px'; 3158 particle.style.height = size + 'px'; 3159 3160 const duration = 0.6 + Math.random() * 0.4; 3161 particle.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 3162 3163 document.body.appendChild(particle); 3164 3165 setTimeout(function() { 3166 particle.remove(); 3167 }, duration * 1000); 3168 } 3169 3170 // Pixel sparkles 3171 const pixelSparkleCount = 40; 3172 3173 for (let i = 0; i < pixelSparkleCount; i++) { 3174 const pixel = document.createElement('div'); 3175 pixel.className = 'pink-pixel-sparkle'; 3176 3177 const pixelColors = ['#fff', '#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1']; 3178 const pixelColor = pixelColors[Math.floor(Math.random() * pixelColors.length)]; 3179 pixel.style.background = pixelColor; 3180 pixel.style.boxShadow = '0 0 3px ' + pixelColor + ', 0 0 6px ' + pixelColor + ', 0 0 9px #fff'; 3181 3182 const angle = Math.random() * Math.PI * 2; 3183 const distance = 30 + Math.random() * 80; 3184 const offsetX = Math.cos(angle) * distance; 3185 const offsetY = Math.sin(angle) * distance; 3186 3187 pixel.style.left = clientX + 'px'; 3188 pixel.style.top = clientY + 'px'; 3189 pixel.style.setProperty('--tx', offsetX + 'px'); 3190 pixel.style.setProperty('--ty', offsetY + 'px'); 3191 3192 const pixelSize = 1 + Math.random() * 2; 3193 pixel.style.width = pixelSize + 'px'; 3194 pixel.style.height = pixelSize + 'px'; 3195 3196 const duration = 0.4 + Math.random() * 0.4; 3197 if (Math.random() > 0.5) { 3198 pixel.style.animation = 'pixel-twinkle ' + duration + 's ease-out forwards'; 3199 } else { 3200 pixel.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 3201 } 3202 3203 document.body.appendChild(pixel); 3204 3205 setTimeout(function() { 3206 pixel.remove(); 3207 }, duration * 1000); 3208 } 3209 3210 // Flash 3211 const flash = document.createElement('div'); 3212 flash.style.position = 'fixed'; 3213 flash.style.left = clientX + 'px'; 3214 flash.style.top = clientY + 'px'; 3215 flash.style.width = '40px'; 3216 flash.style.height = '40px'; 3217 flash.style.borderRadius = '50%'; 3218 flash.style.background = 'radial-gradient(circle, rgba(255, 255, 255, 0.9), rgba(255, 20, 147, 0.6), transparent)'; 3219 flash.style.boxShadow = '0 0 40px #fff, 0 0 60px #ff1493, 0 0 80px #ff69b4'; 3220 flash.style.pointerEvents = 'none'; 3221 flash.style.zIndex = '9999999'; // Above everything including dialogs 3222 flash.style.transform = 'translate(-50%, -50%)'; 3223 flash.style.animation = 'cursor-trail-fade 0.3s ease-out forwards'; 3224 3225 document.body.appendChild(flash); 3226 3227 setTimeout(function() { 3228 flash.remove(); 3229 }, 300); 3230 } 3231 3232 function initPinkParticles() { 3233 if (!checkPinkTheme()) return; 3234 3235 // Use capture phase to catch events before stopPropagation 3236 document.addEventListener('mousemove', function(e) { 3237 if (!pinkThemeActive) return; 3238 3239 createTrailParticle(e.clientX, e.clientY); 3240 createPixelSparkles(e.clientX, e.clientY); 3241 }, true); // Capture phase! 3242 3243 // Throttle main trail 3244 document.addEventListener('mousemove', function(e) { 3245 if (!pinkThemeActive || trailTimer) return; 3246 3247 trailTimer = setTimeout(function() { 3248 trailTimer = null; 3249 }, 30); 3250 }, true); // Capture phase! 3251 3252 // Click explosion - use capture phase 3253 document.addEventListener('click', function(e) { 3254 if (!pinkThemeActive) return; 3255 3256 createExplosion(e.clientX, e.clientY); 3257 }, true); // Capture phase! 3258 } 3259 3260 // Initialize on load 3261 if (document.readyState === 'loading') { 3262 document.addEventListener('DOMContentLoaded', initPinkParticles); 3263 } else { 3264 initPinkParticles(); 3265 } 3266 3267 // Re-check theme if calendar is dynamically added 3268 // Must wait for document.body to exist 3269 function setupMutationObserver() { 3270 if (typeof MutationObserver !== 'undefined' && document.body) { 3271 const observer = new MutationObserver(function(mutations) { 3272 mutations.forEach(function(mutation) { 3273 if (mutation.addedNodes.length > 0) { 3274 mutation.addedNodes.forEach(function(node) { 3275 if (node.nodeType === 1 && node.classList && node.classList.contains('calendar-theme-pink')) { 3276 checkPinkTheme(); 3277 initPinkParticles(); 3278 } 3279 }); 3280 } 3281 }); 3282 }); 3283 3284 observer.observe(document.body, { 3285 childList: true, 3286 subtree: true 3287 }); 3288 } 3289 } 3290 3291 // Setup observer when DOM is ready 3292 if (document.readyState === 'loading') { 3293 document.addEventListener('DOMContentLoaded', setupMutationObserver); 3294 } else { 3295 setupMutationObserver(); 3296 } 3297})(); 3298 3299// End of calendar plugin JavaScript 3300