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 and important namespaces 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 // Get important namespaces 678 let importantNamespaces = ['important']; 679 if (container && container.dataset.importantNamespaces) { 680 try { 681 importantNamespaces = JSON.parse(container.dataset.importantNamespaces); 682 } catch (e) { 683 importantNamespaces = ['important']; 684 } 685 } 686 687 let html = '<div class="day-popup-overlay" onclick="closeDayPopup(\'' + calId + '\')"></div>'; 688 html += '<div class="day-popup-content">'; 689 html += '<div class="day-popup-header">'; 690 html += '<h4>' + displayDate + '</h4>'; 691 html += '<button class="popup-close" onclick="closeDayPopup(\'' + calId + '\')">×</button>'; 692 html += '</div>'; 693 694 html += '<div class="day-popup-body">'; 695 696 if (dayEventsWithConflicts.length === 0) { 697 html += '<p class="no-events-msg">No events on this day</p>'; 698 } else { 699 html += '<div class="popup-events-list">'; 700 dayEventsWithConflicts.forEach(event => { 701 const color = event.color || '#3498db'; 702 703 // Use individual event namespace if available (for multi-namespace support) 704 const eventNamespace = event._namespace !== undefined ? event._namespace : namespace; 705 706 // Check if this is an important namespace event 707 let isImportant = false; 708 if (eventNamespace) { 709 for (const impNs of importantNamespaces) { 710 if (eventNamespace === impNs || eventNamespace.startsWith(impNs + ':')) { 711 isImportant = true; 712 break; 713 } 714 } 715 } 716 717 // Check if this is a continuation (event started before this date) 718 const originalStartDate = event.originalStartDate || event._dateKey || date; 719 const isContinuation = originalStartDate < date; 720 721 // Convert to 12-hour format and handle time ranges 722 let displayTime = ''; 723 if (event.time) { 724 displayTime = formatTimeRange(event.time, event.endTime); 725 } 726 727 // Multi-day indicator 728 let multiDay = ''; 729 if (event.endDate && event.endDate !== date) { 730 const endObj = new Date(event.endDate + 'T00:00:00'); 731 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 732 month: 'short', 733 day: 'numeric' 734 }); 735 } 736 737 // Continuation message 738 if (isContinuation) { 739 const startObj = new Date(originalStartDate + 'T00:00:00'); 740 const startDisplay = startObj.toLocaleDateString('en-US', { 741 weekday: 'short', 742 month: 'short', 743 day: 'numeric' 744 }); 745 html += '<div class="popup-continuation-notice">↪ Continues from ' + startDisplay + '</div>'; 746 } 747 748 const importantClass = isImportant ? ' popup-event-important' : ''; 749 html += '<div class="popup-event-item' + importantClass + '">'; 750 html += '<div class="event-color-bar" style="background: ' + color + ';"></div>'; 751 html += '<div class="popup-event-content">'; 752 753 // Single line with title, time, date range, namespace, and actions 754 html += '<div class="popup-event-main-row">'; 755 html += '<div class="popup-event-info-inline">'; 756 757 // Add star for important events 758 if (isImportant) { 759 html += '<span class="popup-event-star">⭐</span>'; 760 } 761 762 html += '<span class="popup-event-title">' + escapeHtml(event.title) + '</span>'; 763 if (displayTime) { 764 html += '<span class="popup-event-time"> ' + displayTime + '</span>'; 765 } 766 if (multiDay) { 767 html += '<span class="popup-event-multiday">' + multiDay + '</span>'; 768 } 769 if (eventNamespace) { 770 html += '<span class="popup-event-namespace">' + escapeHtml(eventNamespace) + '</span>'; 771 } 772 773 // Add conflict warning badge if event has conflicts 774 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 775 // Build conflict list for tooltip 776 let conflictList = []; 777 event.conflictsWith.forEach(conflict => { 778 let conflictText = conflict.title; 779 if (conflict.time) { 780 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 781 } 782 conflictList.push(conflictText); 783 }); 784 785 html += '<span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 786 } 787 788 html += '</div>'; 789 html += '<div class="popup-event-actions">'; 790 html += '<button type="button" class="event-edit-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">✏️</button>'; 791 html += '<button type="button" class="event-delete-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">️</button>'; 792 html += '</div>'; 793 html += '</div>'; 794 795 // Description on separate line if present 796 if (event.description) { 797 html += '<div class="popup-event-desc">' + renderDescription(event.description) + '</div>'; 798 } 799 800 html += '</div></div>'; 801 }); 802 html += '</div>'; 803 } 804 805 html += '</div>'; 806 807 html += '<div class="day-popup-footer">'; 808 html += '<button class="btn-add-event" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\'); closeDayPopup(\'' + calId + '\')">+ Add Event</button>'; 809 html += '</div>'; 810 811 html += '</div>'; 812 813 popup.innerHTML = html; 814 popup.style.display = 'flex'; 815 816 // Propagate CSS vars from calendar container to popup (popup is outside container in DOM) 817 if (container) { 818 propagateThemeVars(calId, popup.querySelector('.day-popup-content')); 819 } 820 821 // Make popup draggable by header 822 const popupContent = popup.querySelector('.day-popup-content'); 823 const popupHeader = popup.querySelector('.day-popup-header'); 824 825 if (popupContent && popupHeader) { 826 // Reset position to center 827 popupContent.style.position = 'relative'; 828 popupContent.style.left = '0'; 829 popupContent.style.top = '0'; 830 831 // Store drag state on the element itself 832 popupHeader._isDragging = false; 833 834 popupHeader.onmousedown = function(e) { 835 // Ignore if clicking the close button 836 if (e.target.classList.contains('popup-close')) return; 837 838 popupHeader._isDragging = true; 839 popupHeader._dragStartX = e.clientX; 840 popupHeader._dragStartY = e.clientY; 841 842 const rect = popupContent.getBoundingClientRect(); 843 const parentRect = popup.getBoundingClientRect(); 844 popupHeader._initialLeft = rect.left - parentRect.left - (parentRect.width / 2 - rect.width / 2); 845 popupHeader._initialTop = rect.top - parentRect.top - (parentRect.height / 2 - rect.height / 2); 846 847 popupContent.style.transition = 'none'; 848 e.preventDefault(); 849 }; 850 851 popup.onmousemove = function(e) { 852 if (!popupHeader._isDragging) return; 853 854 const deltaX = e.clientX - popupHeader._dragStartX; 855 const deltaY = e.clientY - popupHeader._dragStartY; 856 857 popupContent.style.left = (popupHeader._initialLeft + deltaX) + 'px'; 858 popupContent.style.top = (popupHeader._initialTop + deltaY) + 'px'; 859 }; 860 861 popup.onmouseup = function() { 862 if (popupHeader._isDragging) { 863 popupHeader._isDragging = false; 864 popupContent.style.transition = ''; 865 } 866 }; 867 868 popup.onmouseleave = function() { 869 if (popupHeader._isDragging) { 870 popupHeader._isDragging = false; 871 popupContent.style.transition = ''; 872 } 873 }; 874 } 875}; 876 877// Close day popup 878window.closeDayPopup = function(calId) { 879 const popup = document.getElementById('day-popup-' + calId); 880 if (popup) { 881 popup.style.display = 'none'; 882 } 883}; 884 885// Show events for a specific day (for event list panel) 886window.showDayEvents = function(calId, date, namespace) { 887 const params = new URLSearchParams({ 888 call: 'plugin_calendar', 889 action: 'load_month', 890 year: date.split('-')[0], 891 month: parseInt(date.split('-')[1]), 892 namespace: namespace, 893 _: new Date().getTime() // Cache buster 894 }); 895 896 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 897 method: 'POST', 898 headers: { 899 'Content-Type': 'application/x-www-form-urlencoded', 900 'Cache-Control': 'no-cache, no-store, must-revalidate', 901 'Pragma': 'no-cache' 902 }, 903 body: params.toString() 904 }) 905 .then(r => r.json()) 906 .then(data => { 907 if (data.success) { 908 const eventList = document.getElementById('eventlist-' + calId); 909 const events = data.events; 910 const title = document.getElementById('eventlist-title-' + calId); 911 912 const dateObj = new Date(date + 'T00:00:00'); 913 const displayDate = dateObj.toLocaleDateString('en-US', { 914 weekday: 'short', 915 month: 'short', 916 day: 'numeric' 917 }); 918 919 title.textContent = 'Events - ' + displayDate; 920 921 // Filter events for this day 922 const dayEvents = events[date] || []; 923 924 if (dayEvents.length === 0) { 925 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>'; 926 } else { 927 let html = ''; 928 dayEvents.forEach(event => { 929 html += renderEventItem(event, date, calId, namespace); 930 }); 931 eventList.innerHTML = html; 932 } 933 } 934 }) 935 .catch(err => console.error('Error:', err)); 936}; 937 938// Render a single event item 939window.renderEventItem = function(event, date, calId, namespace) { 940 // Get theme data from container 941 const container = document.getElementById(calId); 942 let themeStyles = {}; 943 let importantNamespaces = ['important']; // default 944 if (container && container.dataset.themeStyles) { 945 try { 946 themeStyles = JSON.parse(container.dataset.themeStyles); 947 } catch (e) { 948 console.error('Failed to parse theme styles:', e); 949 } 950 } 951 // Get important namespaces from container data attribute 952 if (container && container.dataset.importantNamespaces) { 953 try { 954 importantNamespaces = JSON.parse(container.dataset.importantNamespaces); 955 } catch (e) { 956 importantNamespaces = ['important']; 957 } 958 } 959 960 // Check if this event is in the past or today (with 15-minute grace period) 961 const today = new Date(); 962 today.setHours(0, 0, 0, 0); 963 const todayStr = today.toISOString().split('T')[0]; 964 const eventDate = new Date(date + 'T00:00:00'); 965 966 // Helper to determine if event is past with grace period 967 let isPast; 968 if (date < todayStr) { 969 isPast = true; // Past date 970 } else if (date > todayStr) { 971 isPast = false; // Future date 972 } else { 973 // Today - check time with grace period 974 if (event.time && event.time.trim() !== '') { 975 try { 976 const now = new Date(); 977 const eventDateTime = new Date(date + 'T' + event.time); 978 const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000); 979 isPast = now > gracePeriodEnd; 980 } catch (e) { 981 isPast = false; 982 } 983 } else { 984 isPast = false; // No time, treat as future 985 } 986 } 987 988 const isToday = eventDate.getTime() === today.getTime(); 989 990 // Check if this is an important namespace event 991 let eventNamespace = event.namespace || ''; 992 if (!eventNamespace && event._namespace !== undefined) { 993 eventNamespace = event._namespace; 994 } 995 let isImportantNs = false; 996 if (eventNamespace) { 997 for (const impNs of importantNamespaces) { 998 if (eventNamespace === impNs || eventNamespace.startsWith(impNs + ':')) { 999 isImportantNs = true; 1000 break; 1001 } 1002 } 1003 } 1004 1005 // Format date display with day of week 1006 const displayDateKey = event.originalStartDate || date; 1007 const dateObj = new Date(displayDateKey + 'T00:00:00'); 1008 const displayDate = dateObj.toLocaleDateString('en-US', { 1009 weekday: 'short', 1010 month: 'short', 1011 day: 'numeric' 1012 }); 1013 1014 // Convert to 12-hour format and handle time ranges 1015 let displayTime = ''; 1016 if (event.time) { 1017 displayTime = formatTimeRange(event.time, event.endTime); 1018 } 1019 1020 // Multi-day indicator 1021 let multiDay = ''; 1022 if (event.endDate && event.endDate !== displayDateKey) { 1023 const endObj = new Date(event.endDate + 'T00:00:00'); 1024 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 1025 weekday: 'short', 1026 month: 'short', 1027 day: 'numeric' 1028 }); 1029 } 1030 1031 const completedClass = event.completed ? ' event-completed' : ''; 1032 const isTask = event.isTask || false; 1033 const completed = event.completed || false; 1034 const isPastDue = isPast && isTask && !completed; 1035 const pastClass = (isPast && !isPastDue) ? ' event-past' : ''; 1036 const pastDueClass = isPastDue ? ' event-pastdue' : ''; 1037 const importantClass = isImportantNs ? ' event-important' : ''; 1038 const color = event.color || '#3498db'; 1039 1040 // Only inline style needed: border-left-color for event color indicator 1041 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)' : '') + '">'; 1042 1043 html += '<div class="event-info">'; 1044 html += '<div class="event-title-row">'; 1045 // Add star for important namespace events 1046 if (isImportantNs) { 1047 html += '<span class="event-important-star" title="Important">⭐</span> '; 1048 } 1049 html += '<span class="event-title-compact">' + escapeHtml(event.title) + '</span>'; 1050 html += '</div>'; 1051 1052 // Show meta and description for non-past events AND past due tasks 1053 if (!isPast || isPastDue) { 1054 html += '<div class="event-meta-compact">'; 1055 html += '<span class="event-date-time">' + displayDate + multiDay; 1056 if (displayTime) { 1057 html += ' • ' + displayTime; 1058 } 1059 // Add PAST DUE or TODAY badge 1060 if (isPastDue) { 1061 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>'; 1062 } else if (isToday) { 1063 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>'; 1064 } 1065 // Add namespace badge 1066 if (eventNamespace) { 1067 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>'; 1068 } 1069 // Add conflict warning if event has time conflicts 1070 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 1071 let conflictList = []; 1072 event.conflictsWith.forEach(conflict => { 1073 let conflictText = conflict.title; 1074 if (conflict.time) { 1075 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 1076 } 1077 conflictList.push(conflictText); 1078 }); 1079 1080 html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 1081 } 1082 html += '</span>'; 1083 html += '</div>'; 1084 1085 if (event.description) { 1086 html += '<div class="event-desc-compact">' + renderDescription(event.description) + '</div>'; 1087 } 1088 } else { 1089 // For past events (not past due), store data in hidden divs for expand/collapse 1090 html += '<div class="event-meta-compact" style="display: none;">'; 1091 html += '<span class="event-date-time">' + displayDate + multiDay; 1092 if (displayTime) { 1093 html += ' • ' + displayTime; 1094 } 1095 // Add namespace badge for past events too 1096 let eventNamespace = event.namespace || ''; 1097 if (!eventNamespace && event._namespace !== undefined) { 1098 eventNamespace = event._namespace; 1099 } 1100 if (eventNamespace) { 1101 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>'; 1102 } 1103 // Add conflict warning for past events too 1104 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 1105 let conflictList = []; 1106 event.conflictsWith.forEach(conflict => { 1107 let conflictText = conflict.title; 1108 if (conflict.time) { 1109 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 1110 } 1111 conflictList.push(conflictText); 1112 }); 1113 1114 html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 1115 } 1116 html += '</span>'; 1117 html += '</div>'; 1118 1119 if (event.description) { 1120 html += '<div class="event-desc-compact" style="display: none;">' + renderDescription(event.description) + '</div>'; 1121 } 1122 } 1123 1124 html += '</div>'; // event-info 1125 1126 // Use stored namespace from event, fallback to _namespace, then passed namespace 1127 let buttonNamespace = event.namespace || ''; 1128 if (!buttonNamespace && event._namespace !== undefined) { 1129 buttonNamespace = event._namespace; 1130 } 1131 if (!buttonNamespace) { 1132 buttonNamespace = namespace; 1133 } 1134 1135 html += '<div class="event-actions-compact">'; 1136 html += '<button class="event-action-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">️</button>'; 1137 html += '<button class="event-action-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">✏️</button>'; 1138 html += '</div>'; 1139 1140 // Checkbox for tasks - ON THE FAR RIGHT 1141 if (isTask) { 1142 const checked = completed ? 'checked' : ''; 1143 html += '<input type="checkbox" class="task-checkbox" ' + checked + ' onclick="toggleTaskComplete(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\', this.checked)">'; 1144 } 1145 1146 html += '</div>'; 1147 1148 return html; 1149}; 1150 1151// Render description with rich content support 1152window.renderDescription = function(description) { 1153 if (!description) return ''; 1154 1155 // First, convert DokuWiki/Markdown syntax to placeholder tokens (before escaping) 1156 // Use a format that won't be affected by HTML escaping: \x00TOKEN_N\x00 1157 1158 let rendered = description; 1159 const tokens = []; 1160 let tokenIndex = 0; 1161 1162 // Convert DokuWiki image syntax {{image.jpg}} to tokens 1163 rendered = rendered.replace(/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/g, function(match, imagePath, alt) { 1164 imagePath = imagePath.trim(); 1165 alt = alt ? alt.trim() : ''; 1166 1167 let imageHtml; 1168 // Handle external URLs 1169 if (imagePath.match(/^https?:\/\//)) { 1170 imageHtml = '<img src="' + imagePath + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 1171 } else { 1172 // Handle internal DokuWiki images 1173 const imageUrl = DOKU_BASE + 'lib/exe/fetch.php?media=' + encodeURIComponent(imagePath); 1174 imageHtml = '<img src="' + imageUrl + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 1175 } 1176 1177 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1178 tokens[tokenIndex] = imageHtml; 1179 tokenIndex++; 1180 return token; 1181 }); 1182 1183 // Convert DokuWiki link syntax [[link|text]] to tokens 1184 rendered = rendered.replace(/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/g, function(match, link, text) { 1185 link = link.trim(); 1186 text = text ? text.trim() : link; 1187 1188 let linkHtml; 1189 // Handle external URLs 1190 if (link.match(/^https?:\/\//)) { 1191 linkHtml = '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 1192 } else { 1193 // Handle internal DokuWiki links with section anchors 1194 const hashIndex = link.indexOf('#'); 1195 let pagePart = link; 1196 let sectionPart = ''; 1197 1198 if (hashIndex !== -1) { 1199 pagePart = link.substring(0, hashIndex); 1200 sectionPart = link.substring(hashIndex); // Includes the # 1201 } 1202 1203 const wikiUrl = DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pagePart) + sectionPart; 1204 linkHtml = '<a href="' + wikiUrl + '">' + escapeHtml(text) + '</a>'; 1205 } 1206 1207 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1208 tokens[tokenIndex] = linkHtml; 1209 tokenIndex++; 1210 return token; 1211 }); 1212 1213 // Convert markdown-style links [text](url) to tokens 1214 rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) { 1215 text = text.trim(); 1216 url = url.trim(); 1217 1218 let linkHtml; 1219 if (url.match(/^https?:\/\//)) { 1220 linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 1221 } else { 1222 linkHtml = '<a href="' + escapeHtml(url) + '">' + escapeHtml(text) + '</a>'; 1223 } 1224 1225 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1226 tokens[tokenIndex] = linkHtml; 1227 tokenIndex++; 1228 return token; 1229 }); 1230 1231 // Convert plain URLs to tokens 1232 rendered = rendered.replace(/(https?:\/\/[^\s<]+)/g, function(match, url) { 1233 const linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(url) + '</a>'; 1234 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1235 tokens[tokenIndex] = linkHtml; 1236 tokenIndex++; 1237 return token; 1238 }); 1239 1240 // NOW escape the remaining text (tokens are protected with null bytes) 1241 rendered = escapeHtml(rendered); 1242 1243 // Convert newlines to <br> 1244 rendered = rendered.replace(/\n/g, '<br>'); 1245 1246 // DokuWiki text formatting (on escaped text) 1247 // Bold: **text** or __text__ 1248 rendered = rendered.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 1249 rendered = rendered.replace(/__(.+?)__/g, '<strong>$1</strong>'); 1250 1251 // Italic: //text// 1252 rendered = rendered.replace(/\/\/(.+?)\/\//g, '<em>$1</em>'); 1253 1254 // Strikethrough: <del>text</del> 1255 rendered = rendered.replace(/<del>(.+?)<\/del>/g, '<del>$1</del>'); 1256 1257 // Monospace: ''text'' 1258 rendered = rendered.replace(/''(.+?)''/g, '<code>$1</code>'); 1259 1260 // Subscript: <sub>text</sub> 1261 rendered = rendered.replace(/<sub>(.+?)<\/sub>/g, '<sub>$1</sub>'); 1262 1263 // Superscript: <sup>text</sup> 1264 rendered = rendered.replace(/<sup>(.+?)<\/sup>/g, '<sup>$1</sup>'); 1265 1266 // Restore tokens (replace with actual HTML) 1267 for (let i = 0; i < tokens.length; i++) { 1268 const tokenPattern = new RegExp('\x00TOKEN' + i + '\x00', 'g'); 1269 rendered = rendered.replace(tokenPattern, tokens[i]); 1270 } 1271 1272 return rendered; 1273} 1274 1275// Open add event dialog 1276window.openAddEvent = function(calId, namespace, date) { 1277 const dialog = document.getElementById('dialog-' + calId); 1278 const form = document.getElementById('eventform-' + calId); 1279 const title = document.getElementById('dialog-title-' + calId); 1280 const dateField = document.getElementById('event-date-' + calId); 1281 1282 if (!dateField) { 1283 console.error('Date field not found! ID: event-date-' + calId); 1284 return; 1285 } 1286 1287 // Check if there's a filtered namespace active (only for regular calendars) 1288 const calendar = document.getElementById(calId); 1289 const filteredNamespace = calendar ? calendar.dataset.filteredNamespace : null; 1290 1291 // Use filtered namespace if available, otherwise use the passed namespace 1292 const effectiveNamespace = filteredNamespace || namespace; 1293 1294 1295 // Reset form 1296 form.reset(); 1297 document.getElementById('event-id-' + calId).value = ''; 1298 1299 // Store the effective namespace in a hidden field or data attribute 1300 form.dataset.effectiveNamespace = effectiveNamespace; 1301 1302 // Set namespace dropdown to effective namespace 1303 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1304 if (namespaceSelect) { 1305 if (effectiveNamespace && effectiveNamespace !== '*' && effectiveNamespace.indexOf(';') === -1) { 1306 // Set to specific namespace if not wildcard or multi-namespace 1307 namespaceSelect.value = effectiveNamespace; 1308 } else { 1309 // Default to empty (default namespace) for wildcard/multi views 1310 namespaceSelect.value = ''; 1311 } 1312 } 1313 1314 // Clear event namespace from previous edits 1315 delete form.dataset.eventNamespace; 1316 1317 // Set date - use local date, not UTC 1318 let defaultDate = date; 1319 if (!defaultDate) { 1320 // Get the currently displayed month from the calendar container 1321 const container = document.getElementById(calId); 1322 const displayedYear = parseInt(container.getAttribute('data-year')); 1323 const displayedMonth = parseInt(container.getAttribute('data-month')); 1324 1325 1326 if (displayedYear && displayedMonth) { 1327 // Use first day of the displayed month 1328 const year = displayedYear; 1329 const month = String(displayedMonth).padStart(2, '0'); 1330 defaultDate = `${year}-${month}-01`; 1331 } else { 1332 // Fallback to today if attributes not found 1333 const today = new Date(); 1334 const year = today.getFullYear(); 1335 const month = String(today.getMonth() + 1).padStart(2, '0'); 1336 const day = String(today.getDate()).padStart(2, '0'); 1337 defaultDate = `${year}-${month}-${day}`; 1338 } 1339 } 1340 dateField.value = defaultDate; 1341 dateField.removeAttribute('data-original-date'); 1342 1343 // Also set the end date field to the same default (user can change it) 1344 const endDateField = document.getElementById('event-end-date-' + calId); 1345 if (endDateField) { 1346 endDateField.value = ''; // Empty by default (single-day event) 1347 // Set min attribute to help the date picker open on the right month 1348 endDateField.setAttribute('min', defaultDate); 1349 } 1350 1351 // Set default color 1352 document.getElementById('event-color-' + calId).value = '#3498db'; 1353 1354 // Initialize end time dropdown (disabled by default since no start time set) 1355 const endTimeField = document.getElementById('event-end-time-' + calId); 1356 if (endTimeField) { 1357 endTimeField.disabled = true; 1358 endTimeField.value = ''; 1359 } 1360 1361 // Initialize namespace search 1362 initNamespaceSearch(calId); 1363 1364 // Set title 1365 title.textContent = 'Add Event'; 1366 1367 // Show dialog 1368 dialog.style.display = 'flex'; 1369 1370 // Propagate CSS vars to dialog (position:fixed can break inheritance in some templates) 1371 propagateThemeVars(calId, dialog); 1372 1373 // Make dialog draggable 1374 setTimeout(() => makeDialogDraggable(calId), 50); 1375 1376 // Focus title field 1377 setTimeout(() => { 1378 const titleField = document.getElementById('event-title-' + calId); 1379 if (titleField) titleField.focus(); 1380 }, 100); 1381}; 1382 1383// Edit event 1384window.editEvent = function(calId, eventId, date, namespace) { 1385 const params = new URLSearchParams({ 1386 call: 'plugin_calendar', 1387 action: 'get_event', 1388 namespace: namespace, 1389 date: date, 1390 eventId: eventId 1391 }); 1392 1393 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1394 method: 'POST', 1395 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1396 body: params.toString() 1397 }) 1398 .then(r => r.json()) 1399 .then(data => { 1400 if (data.success && data.event) { 1401 const event = data.event; 1402 const dialog = document.getElementById('dialog-' + calId); 1403 const title = document.getElementById('dialog-title-' + calId); 1404 const dateField = document.getElementById('event-date-' + calId); 1405 const form = document.getElementById('eventform-' + calId); 1406 1407 if (!dateField) { 1408 console.error('Date field not found when editing!'); 1409 return; 1410 } 1411 1412 // Store the event's actual namespace for saving (important for namespace=* views) 1413 if (event.namespace !== undefined) { 1414 form.dataset.eventNamespace = event.namespace; 1415 } 1416 1417 // Populate form 1418 document.getElementById('event-id-' + calId).value = event.id; 1419 dateField.value = date; 1420 dateField.setAttribute('data-original-date', date); 1421 1422 const endDateField = document.getElementById('event-end-date-' + calId); 1423 endDateField.value = event.endDate || ''; 1424 // Set min attribute to help date picker open on the start date's month 1425 endDateField.setAttribute('min', date); 1426 1427 document.getElementById('event-title-' + calId).value = event.title; 1428 document.getElementById('event-time-' + calId).value = event.time || ''; 1429 document.getElementById('event-end-time-' + calId).value = event.endTime || ''; 1430 document.getElementById('event-color-' + calId).value = event.color || '#3498db'; 1431 document.getElementById('event-desc-' + calId).value = event.description || ''; 1432 document.getElementById('event-is-task-' + calId).checked = event.isTask || false; 1433 1434 // Update end time options based on start time 1435 if (event.time) { 1436 updateEndTimeOptions(calId); 1437 } 1438 1439 // Initialize namespace search 1440 initNamespaceSearch(calId); 1441 1442 // Set namespace fields if available 1443 const namespaceHidden = document.getElementById('event-namespace-' + calId); 1444 const namespaceSearch = document.getElementById('event-namespace-search-' + calId); 1445 if (namespaceHidden && event.namespace !== undefined) { 1446 // Set the hidden input (this is what gets submitted) 1447 namespaceHidden.value = event.namespace || ''; 1448 // Set the search input to display the namespace 1449 if (namespaceSearch) { 1450 namespaceSearch.value = event.namespace || '(default)'; 1451 } 1452 } else { 1453 // No namespace on event, set to default 1454 if (namespaceHidden) { 1455 namespaceHidden.value = ''; 1456 } 1457 if (namespaceSearch) { 1458 namespaceSearch.value = '(default)'; 1459 } 1460 } 1461 1462 title.textContent = 'Edit Event'; 1463 dialog.style.display = 'flex'; 1464 1465 // Propagate CSS vars to dialog 1466 propagateThemeVars(calId, dialog); 1467 1468 // Make dialog draggable 1469 setTimeout(() => makeDialogDraggable(calId), 50); 1470 } 1471 }) 1472 .catch(err => console.error('Error editing event:', err)); 1473}; 1474 1475// Delete event 1476window.deleteEvent = function(calId, eventId, date, namespace) { 1477 if (!confirm('Delete this event?')) return; 1478 1479 const params = new URLSearchParams({ 1480 call: 'plugin_calendar', 1481 action: 'delete_event', 1482 namespace: namespace, 1483 date: date, 1484 eventId: eventId, 1485 sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : '' 1486 }); 1487 1488 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1489 method: 'POST', 1490 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1491 body: params.toString() 1492 }) 1493 .then(r => r.json()) 1494 .then(data => { 1495 if (data.success) { 1496 // Extract year and month from date 1497 const [year, month] = date.split('-').map(Number); 1498 1499 // Get the calendar's ORIGINAL namespace setting (not the deleted event's namespace) 1500 // This preserves wildcard/multi-namespace views 1501 const container = document.getElementById(calId); 1502 const calendarNamespace = container ? (container.dataset.namespace || '') : namespace; 1503 1504 // Reload calendar data via AJAX with the calendar's original namespace 1505 reloadCalendarData(calId, year, month, calendarNamespace); 1506 } 1507 }) 1508 .catch(err => console.error('Error:', err)); 1509}; 1510 1511// Save event (add or edit) 1512window.saveEventCompact = function(calId, namespace) { 1513 const form = document.getElementById('eventform-' + calId); 1514 1515 // Get namespace from dropdown - this is what the user selected 1516 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1517 const selectedNamespace = namespaceSelect ? namespaceSelect.value : ''; 1518 1519 // ALWAYS use what the user selected in the dropdown 1520 // This allows changing namespace when editing 1521 const finalNamespace = selectedNamespace; 1522 1523 const eventId = document.getElementById('event-id-' + calId).value; 1524 1525 // eventNamespace is the ORIGINAL namespace (only used for finding/deleting old event) 1526 const originalNamespace = form.dataset.eventNamespace; 1527 1528 1529 const dateInput = document.getElementById('event-date-' + calId); 1530 const date = dateInput.value; 1531 const oldDate = dateInput.getAttribute('data-original-date') || date; 1532 const endDate = document.getElementById('event-end-date-' + calId).value; 1533 const title = document.getElementById('event-title-' + calId).value; 1534 const time = document.getElementById('event-time-' + calId).value; 1535 const endTime = document.getElementById('event-end-time-' + calId).value; 1536 const colorSelect = document.getElementById('event-color-' + calId); 1537 let color = colorSelect.value; 1538 1539 // Handle custom color 1540 if (color === 'custom') { 1541 color = colorSelect.dataset.customColor || document.getElementById('event-color-custom-' + calId).value; 1542 } 1543 1544 const description = document.getElementById('event-desc-' + calId).value; 1545 const isTask = document.getElementById('event-is-task-' + calId).checked; 1546 const completed = false; // New tasks are not completed 1547 const isRecurring = document.getElementById('event-recurring-' + calId).checked; 1548 const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value; 1549 const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value; 1550 1551 // New recurrence options 1552 const recurrenceIntervalInput = document.getElementById('event-recurrence-interval-' + calId); 1553 const recurrenceInterval = recurrenceIntervalInput ? parseInt(recurrenceIntervalInput.value) || 1 : 1; 1554 1555 // Weekly: collect selected days 1556 let weekDays = []; 1557 const weeklyOptions = document.getElementById('weekly-options-' + calId); 1558 if (weeklyOptions && recurrenceType === 'weekly') { 1559 const checkboxes = weeklyOptions.querySelectorAll('input[name="weekDays[]"]:checked'); 1560 weekDays = Array.from(checkboxes).map(cb => cb.value); 1561 } 1562 1563 // Monthly: collect day-of-month or ordinal weekday 1564 let monthDay = ''; 1565 let monthlyType = 'dayOfMonth'; 1566 let ordinalWeek = ''; 1567 let ordinalDay = ''; 1568 const monthlyOptions = document.getElementById('monthly-options-' + calId); 1569 if (monthlyOptions && recurrenceType === 'monthly') { 1570 const monthlyTypeRadio = monthlyOptions.querySelector('input[name="monthlyType"]:checked'); 1571 monthlyType = monthlyTypeRadio ? monthlyTypeRadio.value : 'dayOfMonth'; 1572 1573 if (monthlyType === 'dayOfMonth') { 1574 const monthDayInput = document.getElementById('event-month-day-' + calId); 1575 monthDay = monthDayInput ? monthDayInput.value : ''; 1576 } else { 1577 const ordinalSelect = document.getElementById('event-ordinal-' + calId); 1578 const ordinalDaySelect = document.getElementById('event-ordinal-day-' + calId); 1579 ordinalWeek = ordinalSelect ? ordinalSelect.value : '1'; 1580 ordinalDay = ordinalDaySelect ? ordinalDaySelect.value : '0'; 1581 } 1582 } 1583 1584 if (!title) { 1585 alert('Please enter a title'); 1586 return; 1587 } 1588 1589 if (!date) { 1590 alert('Please select a date'); 1591 return; 1592 } 1593 1594 const params = new URLSearchParams({ 1595 call: 'plugin_calendar', 1596 action: 'save_event', 1597 namespace: finalNamespace, 1598 eventId: eventId, 1599 date: date, 1600 oldDate: oldDate, 1601 endDate: endDate, 1602 title: title, 1603 time: time, 1604 endTime: endTime, 1605 color: color, 1606 description: description, 1607 isTask: isTask ? '1' : '0', 1608 completed: completed ? '1' : '0', 1609 isRecurring: isRecurring ? '1' : '0', 1610 recurrenceType: recurrenceType, 1611 recurrenceInterval: recurrenceInterval, 1612 recurrenceEnd: recurrenceEnd, 1613 weekDays: weekDays.join(','), 1614 monthlyType: monthlyType, 1615 monthDay: monthDay, 1616 ordinalWeek: ordinalWeek, 1617 ordinalDay: ordinalDay, 1618 sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : '' 1619 }); 1620 1621 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1622 method: 'POST', 1623 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1624 body: params.toString() 1625 }) 1626 .then(r => r.json()) 1627 .then(data => { 1628 if (data.success) { 1629 closeEventDialog(calId); 1630 1631 // For recurring events, do a full page reload to show all occurrences 1632 if (isRecurring) { 1633 location.reload(); 1634 return; 1635 } 1636 1637 // Extract year and month from the NEW date (in case date was changed) 1638 const [year, month] = date.split('-').map(Number); 1639 1640 // Get the calendar's ORIGINAL namespace setting from the container 1641 // This preserves wildcard/multi-namespace views after editing 1642 const container = document.getElementById(calId); 1643 const calendarNamespace = container ? (container.dataset.namespace || '') : namespace; 1644 1645 // Reload calendar data via AJAX to the month of the event 1646 reloadCalendarData(calId, year, month, calendarNamespace); 1647 } else { 1648 alert('Error: ' + (data.error || 'Unknown error')); 1649 } 1650 }) 1651 .catch(err => { 1652 console.error('Error:', err); 1653 alert('Error saving event'); 1654 }); 1655}; 1656 1657// Reload calendar data without page refresh 1658window.reloadCalendarData = function(calId, year, month, namespace) { 1659 const params = new URLSearchParams({ 1660 call: 'plugin_calendar', 1661 action: 'load_month', 1662 year: year, 1663 month: month, 1664 namespace: namespace, 1665 _: new Date().getTime() // Cache buster 1666 }); 1667 1668 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1669 method: 'POST', 1670 headers: { 1671 'Content-Type': 'application/x-www-form-urlencoded', 1672 'Cache-Control': 'no-cache, no-store, must-revalidate', 1673 'Pragma': 'no-cache' 1674 }, 1675 body: params.toString() 1676 }) 1677 .then(r => r.json()) 1678 .then(data => { 1679 if (data.success) { 1680 const container = document.getElementById(calId); 1681 1682 // Check if this is a full calendar or just event panel 1683 if (container.classList.contains('calendar-compact-container')) { 1684 rebuildCalendar(calId, data.year, data.month, data.events, namespace); 1685 } else if (container.classList.contains('event-panel-standalone')) { 1686 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1687 } 1688 } 1689 }) 1690 .catch(err => console.error('Error:', err)); 1691}; 1692 1693// Close event dialog 1694window.closeEventDialog = function(calId) { 1695 const dialog = document.getElementById('dialog-' + calId); 1696 dialog.style.display = 'none'; 1697}; 1698 1699// Escape HTML 1700window.escapeHtml = function(text) { 1701 const div = document.createElement('div'); 1702 div.textContent = text; 1703 return div.innerHTML; 1704}; 1705 1706// Highlight event when clicking on bar in calendar 1707window.highlightEvent = function(calId, eventId, date) { 1708 1709 // Find the event item in the event list 1710 const eventList = document.querySelector('#' + calId + ' .event-list-compact'); 1711 if (!eventList) { 1712 return; 1713 } 1714 1715 const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]'); 1716 if (!eventItem) { 1717 return; 1718 } 1719 1720 1721 // Get theme 1722 const container = document.getElementById(calId); 1723 const theme = container ? container.dataset.theme : 'matrix'; 1724 const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {}; 1725 1726 1727 // Theme-specific highlight colors 1728 let highlightBg, highlightShadow; 1729 if (theme === 'matrix') { 1730 highlightBg = '#1a3d1a'; // Darker green 1731 highlightShadow = '0 0 20px rgba(0, 204, 7, 0.8), 0 0 40px rgba(0, 204, 7, 0.4)'; 1732 } else if (theme === 'purple') { 1733 highlightBg = '#3d2b4d'; // Darker purple 1734 highlightShadow = '0 0 20px rgba(155, 89, 182, 0.8), 0 0 40px rgba(155, 89, 182, 0.4)'; 1735 } else if (theme === 'professional') { 1736 highlightBg = '#e3f2fd'; // Light blue 1737 highlightShadow = '0 0 20px rgba(74, 144, 226, 0.4)'; 1738 } else if (theme === 'pink') { 1739 highlightBg = '#3d2030'; // Darker pink 1740 highlightShadow = '0 0 20px rgba(255, 20, 147, 0.8), 0 0 40px rgba(255, 20, 147, 0.4)'; 1741 } else if (theme === 'wiki') { 1742 highlightBg = themeStyles.header_bg || '#e8e8e8'; // __background_alt__ 1743 highlightShadow = '0 0 10px rgba(0, 0, 0, 0.15)'; 1744 } 1745 1746 1747 // Store original styles 1748 const originalBg = eventItem.style.background; 1749 const originalShadow = eventItem.style.boxShadow; 1750 1751 // Remove previous highlights (restore their original styles) 1752 const previousHighlights = eventList.querySelectorAll('.event-highlighted'); 1753 previousHighlights.forEach(el => { 1754 el.classList.remove('event-highlighted'); 1755 }); 1756 1757 // Add highlight class and apply theme-aware glow 1758 eventItem.classList.add('event-highlighted'); 1759 1760 // Set CSS properties directly 1761 eventItem.style.setProperty('background', highlightBg, 'important'); 1762 eventItem.style.setProperty('box-shadow', highlightShadow, 'important'); 1763 eventItem.style.setProperty('transition', 'all 0.3s ease-in-out', 'important'); 1764 1765 1766 // Scroll to event 1767 eventItem.scrollIntoView({ 1768 behavior: 'smooth', 1769 block: 'nearest', 1770 inline: 'nearest' 1771 }); 1772 1773 // Remove highlight after 3 seconds and restore original styles 1774 setTimeout(() => { 1775 eventItem.classList.remove('event-highlighted'); 1776 eventItem.style.setProperty('background', originalBg); 1777 eventItem.style.setProperty('box-shadow', originalShadow); 1778 eventItem.style.setProperty('transition', ''); 1779 }, 3000); 1780}; 1781 1782// Toggle recurring event options 1783window.toggleRecurringOptions = function(calId) { 1784 const checkbox = document.getElementById('event-recurring-' + calId); 1785 const options = document.getElementById('recurring-options-' + calId); 1786 1787 if (checkbox && options) { 1788 options.style.display = checkbox.checked ? 'block' : 'none'; 1789 if (checkbox.checked) { 1790 // Initialize the sub-options based on current selection 1791 updateRecurrenceOptions(calId); 1792 } 1793 } 1794}; 1795 1796// Update visible recurrence options based on type (daily/weekly/monthly/yearly) 1797window.updateRecurrenceOptions = function(calId) { 1798 const typeSelect = document.getElementById('event-recurrence-type-' + calId); 1799 const weeklyOptions = document.getElementById('weekly-options-' + calId); 1800 const monthlyOptions = document.getElementById('monthly-options-' + calId); 1801 1802 if (!typeSelect) return; 1803 1804 const recurrenceType = typeSelect.value; 1805 1806 // Hide all conditional options first 1807 if (weeklyOptions) weeklyOptions.style.display = 'none'; 1808 if (monthlyOptions) monthlyOptions.style.display = 'none'; 1809 1810 // Show relevant options 1811 if (recurrenceType === 'weekly' && weeklyOptions) { 1812 weeklyOptions.style.display = 'block'; 1813 // Auto-select today's day of week if nothing selected 1814 const checkboxes = weeklyOptions.querySelectorAll('input[type="checkbox"]'); 1815 const anyChecked = Array.from(checkboxes).some(cb => cb.checked); 1816 if (!anyChecked) { 1817 const today = new Date().getDay(); 1818 const todayCheckbox = weeklyOptions.querySelector('input[value="' + today + '"]'); 1819 if (todayCheckbox) todayCheckbox.checked = true; 1820 } 1821 } else if (recurrenceType === 'monthly' && monthlyOptions) { 1822 monthlyOptions.style.display = 'block'; 1823 // Set default day to current day of month 1824 const monthDayInput = document.getElementById('event-month-day-' + calId); 1825 if (monthDayInput && !monthDayInput.dataset.userSet) { 1826 monthDayInput.value = new Date().getDate(); 1827 } 1828 } 1829}; 1830 1831// Toggle between day-of-month and ordinal weekday for monthly recurrence 1832window.updateMonthlyType = function(calId) { 1833 const dayOfMonthDiv = document.getElementById('monthly-day-' + calId); 1834 const ordinalDiv = document.getElementById('monthly-ordinal-' + calId); 1835 const monthlyOptions = document.getElementById('monthly-options-' + calId); 1836 1837 if (!monthlyOptions) return; 1838 1839 const selectedRadio = monthlyOptions.querySelector('input[name="monthlyType"]:checked'); 1840 if (!selectedRadio) return; 1841 1842 if (selectedRadio.value === 'dayOfMonth') { 1843 if (dayOfMonthDiv) dayOfMonthDiv.style.display = 'flex'; 1844 if (ordinalDiv) ordinalDiv.style.display = 'none'; 1845 } else { 1846 if (dayOfMonthDiv) dayOfMonthDiv.style.display = 'none'; 1847 if (ordinalDiv) ordinalDiv.style.display = 'block'; 1848 1849 // Set defaults based on current date 1850 const now = new Date(); 1851 const dayOfWeek = now.getDay(); 1852 const weekOfMonth = Math.ceil(now.getDate() / 7); 1853 1854 const ordinalSelect = document.getElementById('event-ordinal-' + calId); 1855 const ordinalDaySelect = document.getElementById('event-ordinal-day-' + calId); 1856 1857 if (ordinalSelect && !ordinalSelect.dataset.userSet) { 1858 ordinalSelect.value = weekOfMonth; 1859 } 1860 if (ordinalDaySelect && !ordinalDaySelect.dataset.userSet) { 1861 ordinalDaySelect.value = dayOfWeek; 1862 } 1863 } 1864}; 1865 1866// ============================================================ 1867// Document-level event delegation (guarded - only attach once) 1868// These use event delegation so they work for AJAX-rebuilt content. 1869// ============================================================ 1870if (!window._calendarDelegationInit) { 1871 window._calendarDelegationInit = true; 1872 1873 // ESC closes dialogs, popups, tooltips 1874 document.addEventListener('keydown', function(e) { 1875 if (e.key === 'Escape') { 1876 document.querySelectorAll('.event-dialog-compact').forEach(function(d) { 1877 if (d.style.display === 'flex') d.style.display = 'none'; 1878 }); 1879 document.querySelectorAll('.day-popup').forEach(function(p) { 1880 p.style.display = 'none'; 1881 }); 1882 hideConflictTooltip(); 1883 } 1884 }); 1885 1886 // Conflict tooltip delegation (capture phase for mouseenter/leave) 1887 document.addEventListener('mouseenter', function(e) { 1888 if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) { 1889 showConflictTooltip(e.target); 1890 } 1891 }, true); 1892 1893 document.addEventListener('mouseleave', function(e) { 1894 if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) { 1895 hideConflictTooltip(); 1896 } 1897 }, true); 1898} // end delegation guard 1899 1900// Event panel navigation 1901window.navEventPanel = function(calId, year, month, namespace) { 1902 const params = new URLSearchParams({ 1903 call: 'plugin_calendar', 1904 action: 'load_month', 1905 year: year, 1906 month: month, 1907 namespace: namespace, 1908 _: new Date().getTime() // Cache buster 1909 }); 1910 1911 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1912 method: 'POST', 1913 headers: { 1914 'Content-Type': 'application/x-www-form-urlencoded', 1915 'Cache-Control': 'no-cache, no-store, must-revalidate', 1916 'Pragma': 'no-cache' 1917 }, 1918 body: params.toString() 1919 }) 1920 .then(r => r.json()) 1921 .then(data => { 1922 if (data.success) { 1923 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1924 } 1925 }) 1926 .catch(err => console.error('Error:', err)); 1927}; 1928 1929// Rebuild event panel only 1930window.rebuildEventPanel = function(calId, year, month, events, namespace) { 1931 const container = document.getElementById(calId); 1932 const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 1933 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 1934 1935 // Update month title in new compact header 1936 const monthTitle = container.querySelector('.panel-month-title'); 1937 if (monthTitle) { 1938 monthTitle.textContent = monthNames[month - 1] + ' ' + year; 1939 monthTitle.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1940 monthTitle.setAttribute('title', 'Click to jump to month'); 1941 } 1942 1943 // Fallback: Update old header format if exists 1944 const oldHeader = container.querySelector('.panel-standalone-header h3, .calendar-month-picker'); 1945 if (oldHeader && !monthTitle) { 1946 oldHeader.textContent = monthNames[month - 1] + ' ' + year + ' Events'; 1947 oldHeader.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1948 } 1949 1950 // Update nav buttons 1951 let prevMonth = month - 1; 1952 let prevYear = year; 1953 if (prevMonth < 1) { 1954 prevMonth = 12; 1955 prevYear--; 1956 } 1957 1958 let nextMonth = month + 1; 1959 let nextYear = year; 1960 if (nextMonth > 12) { 1961 nextMonth = 1; 1962 nextYear++; 1963 } 1964 1965 // Update new compact nav buttons 1966 const navBtns = container.querySelectorAll('.panel-nav-btn'); 1967 if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 1968 if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 1969 1970 // Fallback for old nav buttons 1971 const oldNavBtns = container.querySelectorAll('.cal-nav-btn'); 1972 if (oldNavBtns.length > 0 && navBtns.length === 0) { 1973 if (oldNavBtns[0]) oldNavBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 1974 if (oldNavBtns[1]) oldNavBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 1975 } 1976 1977 // Update Today button (works for both old and new) 1978 const todayBtn = container.querySelector('.panel-today-btn, .cal-today-btn, .cal-today-btn-compact'); 1979 if (todayBtn) { 1980 todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`); 1981 } 1982 1983 // Rebuild event list 1984 const eventList = container.querySelector('.event-list-compact'); 1985 if (eventList) { 1986 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 1987 } 1988}; 1989 1990// Open add event for panel 1991window.openAddEventPanel = function(calId, namespace) { 1992 const today = new Date(); 1993 const year = today.getFullYear(); 1994 const month = String(today.getMonth() + 1).padStart(2, '0'); 1995 const day = String(today.getDate()).padStart(2, '0'); 1996 const localDate = `${year}-${month}-${day}`; 1997 openAddEvent(calId, namespace, localDate); 1998}; 1999 2000// Toggle task completion 2001window.toggleTaskComplete = function(calId, eventId, date, namespace, completed) { 2002 const params = new URLSearchParams({ 2003 call: 'plugin_calendar', 2004 action: 'toggle_task', 2005 namespace: namespace, 2006 date: date, 2007 eventId: eventId, 2008 completed: completed ? '1' : '0', 2009 sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : '' 2010 }); 2011 2012 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 2013 method: 'POST', 2014 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 2015 body: params.toString() 2016 }) 2017 .then(r => r.json()) 2018 .then(data => { 2019 if (data.success) { 2020 const [year, month] = date.split('-').map(Number); 2021 2022 // Get the calendar's ORIGINAL namespace setting from the container 2023 const container = document.getElementById(calId); 2024 const calendarNamespace = container ? (container.dataset.namespace || '') : namespace; 2025 2026 reloadCalendarData(calId, year, month, calendarNamespace); 2027 } 2028 }) 2029 .catch(err => console.error('Error toggling task:', err)); 2030}; 2031 2032// Make dialog draggable 2033window.makeDialogDraggable = function(calId) { 2034 const dialog = document.getElementById('dialog-content-' + calId); 2035 const handle = document.getElementById('drag-handle-' + calId); 2036 2037 if (!dialog || !handle) return; 2038 2039 // Remove any existing drag setup to prevent duplicate listeners 2040 if (handle._dragCleanup) { 2041 handle._dragCleanup(); 2042 } 2043 2044 // Reset position when dialog opens 2045 dialog.style.transform = ''; 2046 2047 let isDragging = false; 2048 let currentX = 0; 2049 let currentY = 0; 2050 let initialX; 2051 let initialY; 2052 let xOffset = 0; 2053 let yOffset = 0; 2054 2055 function dragStart(e) { 2056 // Only start drag if clicking on the handle itself, not buttons inside it 2057 if (e.target.tagName === 'BUTTON') return; 2058 2059 initialX = e.clientX - xOffset; 2060 initialY = e.clientY - yOffset; 2061 isDragging = true; 2062 handle.style.cursor = 'grabbing'; 2063 } 2064 2065 function drag(e) { 2066 if (isDragging) { 2067 e.preventDefault(); 2068 currentX = e.clientX - initialX; 2069 currentY = e.clientY - initialY; 2070 xOffset = currentX; 2071 yOffset = currentY; 2072 dialog.style.transform = `translate(${currentX}px, ${currentY}px)`; 2073 } 2074 } 2075 2076 function dragEnd(e) { 2077 if (isDragging) { 2078 initialX = currentX; 2079 initialY = currentY; 2080 isDragging = false; 2081 handle.style.cursor = 'move'; 2082 } 2083 } 2084 2085 // Add listeners 2086 handle.addEventListener('mousedown', dragStart); 2087 document.addEventListener('mousemove', drag); 2088 document.addEventListener('mouseup', dragEnd); 2089 2090 // Store cleanup function to remove listeners later 2091 handle._dragCleanup = function() { 2092 handle.removeEventListener('mousedown', dragStart); 2093 document.removeEventListener('mousemove', drag); 2094 document.removeEventListener('mouseup', dragEnd); 2095 }; 2096}; 2097 2098// Toggle expand/collapse for past events 2099window.togglePastEventExpand = function(element) { 2100 // Stop propagation to prevent any parent click handlers 2101 event.stopPropagation(); 2102 2103 const meta = element.querySelector(".event-meta-compact"); 2104 const desc = element.querySelector(".event-desc-compact"); 2105 2106 // Toggle visibility 2107 if (meta.style.display === "none") { 2108 // Expand 2109 meta.style.display = "block"; 2110 if (desc) desc.style.display = "block"; 2111 element.classList.add("event-past-expanded"); 2112 } else { 2113 // Collapse 2114 meta.style.display = "none"; 2115 if (desc) desc.style.display = "none"; 2116 element.classList.remove("event-past-expanded"); 2117 } 2118}; 2119 2120// Filter calendar by namespace when clicking namespace badge (guarded) 2121if (!window._calendarClickDelegationInit) { 2122 window._calendarClickDelegationInit = true; 2123 document.addEventListener('click', function(e) { 2124 if (e.target.classList.contains('event-namespace-badge')) { 2125 const namespace = e.target.textContent; 2126 const calendar = e.target.closest('.calendar-compact-container'); 2127 2128 if (!calendar) return; 2129 2130 const calId = calendar.id; 2131 2132 // Use AJAX reload to filter both calendar grid and event list 2133 filterCalendarByNamespace(calId, namespace); 2134 } 2135 }); 2136} // end click delegation guard 2137 2138// Update the displayed filtered namespace in event list header 2139// Legacy badge removed - namespace filtering still works but badge no longer shown 2140window.updateFilteredNamespaceDisplay = function(calId, namespace) { 2141 const calendar = document.getElementById(calId); 2142 if (!calendar) return; 2143 2144 const headerContent = calendar.querySelector('.event-list-header-content'); 2145 if (!headerContent) return; 2146 2147 // Remove any existing filter badge (cleanup) 2148 let filterBadge = headerContent.querySelector('.namespace-filter-badge'); 2149 if (filterBadge) { 2150 filterBadge.remove(); 2151 } 2152}; 2153 2154// Clear namespace filter 2155window.clearNamespaceFilter = function(calId) { 2156 2157 const container = document.getElementById(calId); 2158 if (!container) { 2159 console.error('Calendar container not found:', calId); 2160 return; 2161 } 2162 2163 // Immediately hide/remove the filter badge 2164 const filterBadge = container.querySelector('.calendar-namespace-filter'); 2165 if (filterBadge) { 2166 filterBadge.style.display = 'none'; 2167 filterBadge.remove(); 2168 } 2169 2170 // Get current year and month 2171 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 2172 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 2173 2174 // Get original namespace (what the calendar was initialized with) 2175 const originalNamespace = container.dataset.originalNamespace || ''; 2176 2177 // Also check for sidebar widget 2178 const sidebarContainer = document.getElementById('sidebar-widget-' + calId); 2179 if (sidebarContainer) { 2180 // For sidebar widget, just reload the page without namespace filter 2181 // Remove the namespace from the URL and reload 2182 const url = new URL(window.location.href); 2183 url.searchParams.delete('namespace'); 2184 window.location.href = url.toString(); 2185 return; 2186 } 2187 2188 // For regular calendar, reload calendar with original namespace 2189 navCalendar(calId, year, month, originalNamespace); 2190}; 2191 2192window.clearNamespaceFilterPanel = function(calId) { 2193 2194 const container = document.getElementById(calId); 2195 if (!container) { 2196 console.error('Event panel container not found:', calId); 2197 return; 2198 } 2199 2200 // Get current year and month from URL params or container 2201 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 2202 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 2203 2204 // Get original namespace (what the panel was initialized with) 2205 const originalNamespace = container.dataset.originalNamespace || ''; 2206 2207 2208 // Reload event panel with original namespace 2209 navEventPanel(calId, year, month, originalNamespace); 2210}; 2211 2212// Color picker functions 2213window.updateCustomColorPicker = function(calId) { 2214 const select = document.getElementById('event-color-' + calId); 2215 const picker = document.getElementById('event-color-custom-' + calId); 2216 2217 if (select.value === 'custom') { 2218 // Show color picker 2219 picker.style.display = 'inline-block'; 2220 picker.click(); // Open color picker 2221 } else { 2222 // Hide color picker and sync value 2223 picker.style.display = 'none'; 2224 picker.value = select.value; 2225 } 2226}; 2227 2228function updateColorFromPicker(calId) { 2229 const select = document.getElementById('event-color-' + calId); 2230 const picker = document.getElementById('event-color-custom-' + calId); 2231 2232 // Set select to custom and update its underlying value 2233 select.value = 'custom'; 2234 // Store the actual color value in a data attribute 2235 select.dataset.customColor = picker.value; 2236} 2237 2238// Toggle past events visibility 2239window.togglePastEvents = function(calId) { 2240 const content = document.getElementById('past-events-' + calId); 2241 const arrow = document.getElementById('past-arrow-' + calId); 2242 2243 if (!content || !arrow) { 2244 console.error('Past events elements not found for:', calId); 2245 return; 2246 } 2247 2248 // Check computed style instead of inline style 2249 const isHidden = window.getComputedStyle(content).display === 'none'; 2250 2251 if (isHidden) { 2252 content.style.display = 'block'; 2253 arrow.textContent = '▼'; 2254 } else { 2255 content.style.display = 'none'; 2256 arrow.textContent = '▶'; 2257 } 2258}; 2259 2260// Fuzzy match scoring function 2261window.fuzzyMatch = function(pattern, str) { 2262 pattern = pattern.toLowerCase(); 2263 str = str.toLowerCase(); 2264 2265 let patternIdx = 0; 2266 let score = 0; 2267 let consecutiveMatches = 0; 2268 2269 for (let i = 0; i < str.length; i++) { 2270 if (patternIdx < pattern.length && str[i] === pattern[patternIdx]) { 2271 score += 1 + consecutiveMatches; 2272 consecutiveMatches++; 2273 patternIdx++; 2274 } else { 2275 consecutiveMatches = 0; 2276 } 2277 } 2278 2279 // Return null if not all characters matched 2280 if (patternIdx !== pattern.length) { 2281 return null; 2282 } 2283 2284 // Bonus for exact match 2285 if (str === pattern) { 2286 score += 100; 2287 } 2288 2289 // Bonus for starts with 2290 if (str.startsWith(pattern)) { 2291 score += 50; 2292 } 2293 2294 return score; 2295}; 2296 2297// Initialize namespace search for a calendar 2298window.initNamespaceSearch = function(calId) { 2299 const searchInput = document.getElementById('event-namespace-search-' + calId); 2300 const hiddenInput = document.getElementById('event-namespace-' + calId); 2301 const dropdown = document.getElementById('event-namespace-dropdown-' + calId); 2302 const dataElement = document.getElementById('namespaces-data-' + calId); 2303 2304 if (!searchInput || !hiddenInput || !dropdown || !dataElement) { 2305 return; // Elements not found 2306 } 2307 2308 let namespaces = []; 2309 try { 2310 namespaces = JSON.parse(dataElement.textContent); 2311 } catch (e) { 2312 console.error('Failed to parse namespaces data:', e); 2313 return; 2314 } 2315 2316 let selectedIndex = -1; 2317 2318 // Filter and show dropdown 2319 function filterNamespaces(query) { 2320 if (!query || query.trim() === '') { 2321 // Show all namespaces when empty 2322 hiddenInput.value = ''; 2323 const results = namespaces.slice(0, 20); // Limit to 20 2324 showDropdown(results); 2325 return; 2326 } 2327 2328 // Fuzzy match and score 2329 const matches = []; 2330 for (let i = 0; i < namespaces.length; i++) { 2331 const score = fuzzyMatch(query, namespaces[i]); 2332 if (score !== null) { 2333 matches.push({ namespace: namespaces[i], score: score }); 2334 } 2335 } 2336 2337 // Sort by score (descending) 2338 matches.sort((a, b) => b.score - a.score); 2339 2340 // Take top 20 results 2341 const results = matches.slice(0, 20).map(m => m.namespace); 2342 showDropdown(results); 2343 } 2344 2345 function showDropdown(results) { 2346 dropdown.innerHTML = ''; 2347 selectedIndex = -1; 2348 2349 if (results.length === 0) { 2350 dropdown.style.display = 'none'; 2351 return; 2352 } 2353 2354 // Add (default) option 2355 const defaultOption = document.createElement('div'); 2356 defaultOption.className = 'namespace-option'; 2357 defaultOption.textContent = '(default)'; 2358 defaultOption.dataset.value = ''; 2359 dropdown.appendChild(defaultOption); 2360 2361 results.forEach(ns => { 2362 const option = document.createElement('div'); 2363 option.className = 'namespace-option'; 2364 option.textContent = ns; 2365 option.dataset.value = ns; 2366 dropdown.appendChild(option); 2367 }); 2368 2369 dropdown.style.display = 'block'; 2370 } 2371 2372 function hideDropdown() { 2373 dropdown.style.display = 'none'; 2374 selectedIndex = -1; 2375 } 2376 2377 function selectOption(namespace) { 2378 hiddenInput.value = namespace; 2379 searchInput.value = namespace || '(default)'; 2380 hideDropdown(); 2381 } 2382 2383 // Event listeners 2384 searchInput.addEventListener('input', function(e) { 2385 filterNamespaces(e.target.value); 2386 }); 2387 2388 searchInput.addEventListener('focus', function(e) { 2389 filterNamespaces(e.target.value); 2390 }); 2391 2392 searchInput.addEventListener('blur', function(e) { 2393 // Delay to allow click on dropdown 2394 setTimeout(hideDropdown, 200); 2395 }); 2396 2397 searchInput.addEventListener('keydown', function(e) { 2398 const options = dropdown.querySelectorAll('.namespace-option'); 2399 2400 if (e.key === 'ArrowDown') { 2401 e.preventDefault(); 2402 selectedIndex = Math.min(selectedIndex + 1, options.length - 1); 2403 updateSelection(options); 2404 } else if (e.key === 'ArrowUp') { 2405 e.preventDefault(); 2406 selectedIndex = Math.max(selectedIndex - 1, -1); 2407 updateSelection(options); 2408 } else if (e.key === 'Enter') { 2409 e.preventDefault(); 2410 if (selectedIndex >= 0 && options[selectedIndex]) { 2411 selectOption(options[selectedIndex].dataset.value); 2412 } 2413 } else if (e.key === 'Escape') { 2414 hideDropdown(); 2415 } 2416 }); 2417 2418 function updateSelection(options) { 2419 options.forEach((opt, idx) => { 2420 if (idx === selectedIndex) { 2421 opt.classList.add('selected'); 2422 opt.scrollIntoView({ block: 'nearest' }); 2423 } else { 2424 opt.classList.remove('selected'); 2425 } 2426 }); 2427 } 2428 2429 // Click on dropdown option 2430 dropdown.addEventListener('mousedown', function(e) { 2431 if (e.target.classList.contains('namespace-option')) { 2432 selectOption(e.target.dataset.value); 2433 } 2434 }); 2435}; 2436 2437// Update end time options based on start time selection 2438window.updateEndTimeOptions = function(calId) { 2439 const startTimeSelect = document.getElementById('event-time-' + calId); 2440 const endTimeSelect = document.getElementById('event-end-time-' + calId); 2441 const startDateField = document.getElementById('event-date-' + calId); 2442 const endDateField = document.getElementById('event-end-date-' + calId); 2443 2444 if (!startTimeSelect || !endTimeSelect) return; 2445 2446 const startTime = startTimeSelect.value; 2447 const startDate = startDateField ? startDateField.value : ''; 2448 const endDate = endDateField ? endDateField.value : ''; 2449 2450 // Check if end date is different from start date (multi-day event) 2451 const isMultiDay = endDate && endDate !== startDate; 2452 2453 // If start time is empty (all day), disable end time and reset 2454 if (!startTime) { 2455 endTimeSelect.disabled = true; 2456 endTimeSelect.value = ''; 2457 // Show all options again 2458 Array.from(endTimeSelect.options).forEach(opt => { 2459 opt.disabled = false; 2460 opt.style.display = ''; 2461 }); 2462 return; 2463 } 2464 2465 // Enable end time select 2466 endTimeSelect.disabled = false; 2467 2468 // If multi-day event, allow all end times (event can end at any time on the end date) 2469 if (isMultiDay) { 2470 Array.from(endTimeSelect.options).forEach(opt => { 2471 opt.disabled = false; 2472 opt.style.display = ''; 2473 }); 2474 return; 2475 } 2476 2477 // Same-day event: Convert start time to minutes and filter options 2478 const [startHour, startMinute] = startTime.split(':').map(Number); 2479 const startMinutes = startHour * 60 + startMinute; 2480 2481 // Get current end time value 2482 const currentEndTime = endTimeSelect.value; 2483 let currentEndMinutes = 0; 2484 if (currentEndTime) { 2485 const [h, m] = currentEndTime.split(':').map(Number); 2486 currentEndMinutes = h * 60 + m; 2487 } 2488 2489 // Disable/hide options before or equal to start time 2490 let firstValidOption = null; 2491 Array.from(endTimeSelect.options).forEach(opt => { 2492 if (opt.value === '') { 2493 // Keep "Same as start" option enabled 2494 opt.disabled = false; 2495 opt.style.display = ''; 2496 return; 2497 } 2498 2499 const [h, m] = opt.value.split(':').map(Number); 2500 const optMinutes = h * 60 + m; 2501 2502 if (optMinutes <= startMinutes) { 2503 // Disable and hide times at or before start 2504 opt.disabled = true; 2505 opt.style.display = 'none'; 2506 } else { 2507 // Enable and show times after start 2508 opt.disabled = false; 2509 opt.style.display = ''; 2510 if (!firstValidOption) { 2511 firstValidOption = opt.value; 2512 } 2513 } 2514 }); 2515 2516 // If current end time is now invalid, set a new one 2517 if (currentEndTime && currentEndMinutes <= startMinutes) { 2518 // Try to set to 1 hour after start 2519 let endHour = startHour + 1; 2520 let endMinute = startMinute; 2521 2522 if (endHour >= 24) { 2523 endHour = 23; 2524 endMinute = 45; 2525 } 2526 2527 const suggestedEndTime = String(endHour).padStart(2, '0') + ':' + String(endMinute).padStart(2, '0'); 2528 2529 // Check if suggested time exists and is valid 2530 const suggestedOpt = Array.from(endTimeSelect.options).find(opt => opt.value === suggestedEndTime && !opt.disabled); 2531 2532 if (suggestedOpt) { 2533 endTimeSelect.value = suggestedEndTime; 2534 } else if (firstValidOption) { 2535 endTimeSelect.value = firstValidOption; 2536 } else { 2537 endTimeSelect.value = ''; 2538 } 2539 } 2540}; 2541 2542// Check for time conflicts between events on the same date 2543window.checkTimeConflicts = function(events, currentEventId) { 2544 const conflicts = []; 2545 2546 // Group events by date 2547 const eventsByDate = {}; 2548 for (const [date, dateEvents] of Object.entries(events)) { 2549 if (!Array.isArray(dateEvents)) continue; 2550 2551 dateEvents.forEach(evt => { 2552 if (!evt.time || evt.id === currentEventId) return; // Skip all-day events and current event 2553 2554 if (!eventsByDate[date]) eventsByDate[date] = []; 2555 eventsByDate[date].push(evt); 2556 }); 2557 } 2558 2559 // Check for overlaps on each date 2560 for (const [date, dateEvents] of Object.entries(eventsByDate)) { 2561 for (let i = 0; i < dateEvents.length; i++) { 2562 for (let j = i + 1; j < dateEvents.length; j++) { 2563 const evt1 = dateEvents[i]; 2564 const evt2 = dateEvents[j]; 2565 2566 if (eventsOverlap(evt1, evt2)) { 2567 // Mark both events as conflicting 2568 if (!evt1.hasConflict) evt1.hasConflict = true; 2569 if (!evt2.hasConflict) evt2.hasConflict = true; 2570 2571 // Store conflict info 2572 if (!evt1.conflictsWith) evt1.conflictsWith = []; 2573 if (!evt2.conflictsWith) evt2.conflictsWith = []; 2574 2575 evt1.conflictsWith.push({id: evt2.id, title: evt2.title, time: evt2.time, endTime: evt2.endTime}); 2576 evt2.conflictsWith.push({id: evt1.id, title: evt1.title, time: evt1.time, endTime: evt1.endTime}); 2577 } 2578 } 2579 } 2580 } 2581 2582 return events; 2583}; 2584 2585// Check if two events overlap in time 2586function eventsOverlap(evt1, evt2) { 2587 if (!evt1.time || !evt2.time) return false; // All-day events don't conflict 2588 2589 const start1 = evt1.time; 2590 const end1 = evt1.endTime || evt1.time; // If no end time, treat as same as start 2591 2592 const start2 = evt2.time; 2593 const end2 = evt2.endTime || evt2.time; 2594 2595 // Convert to minutes for easier comparison 2596 const start1Mins = timeToMinutes(start1); 2597 const end1Mins = timeToMinutes(end1); 2598 const start2Mins = timeToMinutes(start2); 2599 const end2Mins = timeToMinutes(end2); 2600 2601 // Check for overlap 2602 // Events overlap if: start1 < end2 AND start2 < end1 2603 return start1Mins < end2Mins && start2Mins < end1Mins; 2604} 2605 2606// Convert HH:MM time to minutes since midnight 2607function timeToMinutes(timeStr) { 2608 const [hours, minutes] = timeStr.split(':').map(Number); 2609 return hours * 60 + minutes; 2610} 2611 2612// Format time range for display 2613window.formatTimeRange = function(startTime, endTime) { 2614 if (!startTime) return ''; 2615 2616 const formatTime = (timeStr) => { 2617 const [hour24, minute] = timeStr.split(':').map(Number); 2618 const hour12 = hour24 === 0 ? 12 : (hour24 > 12 ? hour24 - 12 : hour24); 2619 const ampm = hour24 < 12 ? 'AM' : 'PM'; 2620 return hour12 + ':' + String(minute).padStart(2, '0') + ' ' + ampm; 2621 }; 2622 2623 if (!endTime || endTime === startTime) { 2624 return formatTime(startTime); 2625 } 2626 2627 return formatTime(startTime) + ' - ' + formatTime(endTime); 2628}; 2629 2630// Track last known mouse position for tooltip positioning fallback 2631var _lastMouseX = 0, _lastMouseY = 0; 2632document.addEventListener('mousemove', function(e) { 2633 _lastMouseX = e.clientX; 2634 _lastMouseY = e.clientY; 2635}); 2636 2637// Show custom conflict tooltip 2638window.showConflictTooltip = function(badgeElement) { 2639 // Remove any existing tooltip 2640 hideConflictTooltip(); 2641 2642 // Get conflict data (base64-encoded JSON to avoid attribute quote issues) 2643 const conflictsRaw = badgeElement.getAttribute('data-conflicts'); 2644 if (!conflictsRaw) return; 2645 2646 let conflicts; 2647 try { 2648 conflicts = JSON.parse(decodeURIComponent(escape(atob(conflictsRaw)))); 2649 } catch (e) { 2650 // Fallback: try parsing as plain JSON (for PHP-rendered badges) 2651 try { 2652 conflicts = JSON.parse(conflictsRaw); 2653 } catch (e2) { 2654 console.error('Failed to parse conflicts:', e2); 2655 return; 2656 } 2657 } 2658 2659 // Get theme from the calendar container via CSS variables 2660 // Try closest ancestor first, then fall back to any calendar on the page 2661 let containerEl = badgeElement.closest('[id^="cal_"], [id^="panel_"], [id^="sidebar-widget-"], .calendar-compact-container, .event-panel-standalone'); 2662 if (!containerEl) { 2663 // Badge might be inside a day popup (appended to body) - find any calendar container 2664 containerEl = document.querySelector('.calendar-compact-container, .event-panel-standalone, [id^="sidebar-widget-"]'); 2665 } 2666 const cs = containerEl ? getComputedStyle(containerEl) : null; 2667 2668 const bg = cs ? cs.getPropertyValue('--background-site').trim() || '#242424' : '#242424'; 2669 const border = cs ? cs.getPropertyValue('--border-main').trim() || '#00cc07' : '#00cc07'; 2670 const textPrimary = cs ? cs.getPropertyValue('--text-primary').trim() || '#00cc07' : '#00cc07'; 2671 const textDim = cs ? cs.getPropertyValue('--text-dim').trim() || '#00aa00' : '#00aa00'; 2672 const shadow = cs ? cs.getPropertyValue('--shadow-color').trim() || 'rgba(0, 204, 7, 0.3)' : 'rgba(0, 204, 7, 0.3)'; 2673 2674 // Create tooltip 2675 const tooltip = document.createElement('div'); 2676 tooltip.id = 'conflict-tooltip'; 2677 tooltip.className = 'conflict-tooltip'; 2678 2679 // Apply theme styles 2680 tooltip.style.background = bg; 2681 tooltip.style.borderColor = border; 2682 tooltip.style.color = textPrimary; 2683 tooltip.style.boxShadow = '0 4px 12px ' + shadow; 2684 2685 // Build content with themed colors 2686 let html = '<div class="conflict-tooltip-header" style="background: ' + border + '; color: ' + bg + '; border-bottom: 1px solid ' + border + ';">⚠️ Time Conflicts</div>'; 2687 html += '<div class="conflict-tooltip-body">'; 2688 conflicts.forEach(conflict => { 2689 html += '<div class="conflict-item" style="color: ' + textDim + '; border-bottom-color: ' + border + ';">• ' + escapeHtml(conflict) + '</div>'; 2690 }); 2691 html += '</div>'; 2692 2693 tooltip.innerHTML = html; 2694 document.body.appendChild(tooltip); 2695 2696 // Position tooltip 2697 const rect = badgeElement.getBoundingClientRect(); 2698 const tooltipRect = tooltip.getBoundingClientRect(); 2699 2700 // Position above the badge, centered 2701 let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); 2702 let top = rect.top - tooltipRect.height - 8; 2703 2704 // Keep tooltip within viewport 2705 if (left < 10) left = 10; 2706 if (left + tooltipRect.width > window.innerWidth - 10) { 2707 left = window.innerWidth - tooltipRect.width - 10; 2708 } 2709 if (top < 10) { 2710 // If not enough room above, show below 2711 top = rect.bottom + 8; 2712 } 2713 2714 tooltip.style.left = left + 'px'; 2715 tooltip.style.top = top + 'px'; 2716 tooltip.style.opacity = '1'; 2717}; 2718 2719// Hide conflict tooltip 2720window.hideConflictTooltip = function() { 2721 const tooltip = document.getElementById('conflict-tooltip'); 2722 if (tooltip) { 2723 tooltip.remove(); 2724 } 2725}; 2726 2727// Fuzzy search helper for event filtering - normalizes text for matching 2728function eventSearchNormalize(text) { 2729 if (typeof text !== 'string') { 2730 console.log('[eventSearchNormalize] WARNING: text is not a string:', typeof text, text); 2731 return ''; 2732 } 2733 return text 2734 .toLowerCase() 2735 .trim() 2736 // Remove common punctuation that might differ 2737 .replace(/[''\u2018\u2019]/g, '') // Remove apostrophes/quotes 2738 .replace(/["""\u201C\u201D]/g, '') // Remove smart quotes 2739 .replace(/[-–—]/g, ' ') // Dashes to spaces 2740 .replace(/[.,!?;:]/g, '') // Remove punctuation 2741 .replace(/\s+/g, ' ') // Normalize whitespace 2742 .trim(); 2743} 2744 2745// Check if search term matches text for event filtering 2746function eventSearchMatch(text, searchTerm) { 2747 const normalizedText = eventSearchNormalize(text); 2748 const normalizedSearch = eventSearchNormalize(searchTerm); 2749 2750 // Direct match after normalization 2751 if (normalizedText.includes(normalizedSearch)) { 2752 return true; 2753 } 2754 2755 // Split search into words and check if all words are present 2756 const searchWords = normalizedSearch.split(' ').filter(w => w.length > 0); 2757 if (searchWords.length > 1) { 2758 return searchWords.every(word => normalizedText.includes(word)); 2759 } 2760 2761 return false; 2762} 2763 2764// Filter events by search term 2765window.filterEvents = function(calId, searchTerm) { 2766 const eventList = document.getElementById('eventlist-' + calId); 2767 const searchClear = document.getElementById('search-clear-' + calId); 2768 const searchMode = document.getElementById('search-mode-' + calId); 2769 2770 if (!eventList) return; 2771 2772 // Check if we're in "all dates" mode 2773 const isAllDatesMode = searchMode && searchMode.classList.contains('all-dates'); 2774 2775 // Show/hide clear button 2776 if (searchClear) { 2777 searchClear.style.display = searchTerm ? 'block' : 'none'; 2778 } 2779 2780 searchTerm = searchTerm.trim(); 2781 2782 // If all-dates mode and we have a search term, do AJAX search 2783 if (isAllDatesMode && searchTerm.length >= 2) { 2784 searchAllDates(calId, searchTerm); 2785 return; 2786 } 2787 2788 // If all-dates mode but search cleared, restore normal view 2789 if (isAllDatesMode && !searchTerm) { 2790 // Remove search results container if exists 2791 const resultsContainer = eventList.querySelector('.all-dates-results'); 2792 if (resultsContainer) { 2793 resultsContainer.remove(); 2794 } 2795 // Show normal event items 2796 eventList.querySelectorAll('.event-compact-item').forEach(item => { 2797 item.style.display = ''; 2798 }); 2799 // Show past events toggle if it exists 2800 const pastToggle = eventList.querySelector('.past-events-toggle'); 2801 if (pastToggle) pastToggle.style.display = ''; 2802 } 2803 2804 // Get all event items 2805 const eventItems = eventList.querySelectorAll('.event-compact-item'); 2806 let visibleCount = 0; 2807 let hiddenPastCount = 0; 2808 2809 eventItems.forEach(item => { 2810 const title = item.querySelector('.event-title-compact'); 2811 const description = item.querySelector('.event-desc-compact'); 2812 const dateTime = item.querySelector('.event-date-time'); 2813 2814 // Build searchable text 2815 let searchableText = ''; 2816 if (title) searchableText += title.textContent + ' '; 2817 if (description) searchableText += description.textContent + ' '; 2818 if (dateTime) searchableText += dateTime.textContent + ' '; 2819 2820 // Check if matches search using fuzzy matching 2821 const matches = !searchTerm || eventSearchMatch(searchableText, searchTerm); 2822 2823 if (matches) { 2824 item.style.display = ''; 2825 visibleCount++; 2826 } else { 2827 item.style.display = 'none'; 2828 // Check if this is a past event 2829 if (item.classList.contains('event-past') || item.classList.contains('event-completed')) { 2830 hiddenPastCount++; 2831 } 2832 } 2833 }); 2834 2835 // Update past events toggle if it exists 2836 const pastToggle = eventList.querySelector('.past-events-toggle'); 2837 const pastLabel = eventList.querySelector('.past-events-label'); 2838 const pastContent = document.getElementById('past-events-' + calId); 2839 2840 if (pastToggle && pastLabel && pastContent) { 2841 const visiblePastEvents = pastContent.querySelectorAll('.event-compact-item:not([style*="display: none"])'); 2842 const totalPastVisible = visiblePastEvents.length; 2843 2844 if (totalPastVisible > 0) { 2845 pastLabel.textContent = `Past Events (${totalPastVisible})`; 2846 pastToggle.style.display = ''; 2847 } else { 2848 pastToggle.style.display = 'none'; 2849 } 2850 } 2851 2852 // Show "no results" message if nothing visible (only for month mode, not all-dates mode) 2853 let noResultsMsg = eventList.querySelector('.no-search-results'); 2854 if (visibleCount === 0 && searchTerm && !isAllDatesMode) { 2855 if (!noResultsMsg) { 2856 noResultsMsg = document.createElement('p'); 2857 noResultsMsg.className = 'no-search-results no-events-msg'; 2858 noResultsMsg.textContent = 'No events match your search'; 2859 eventList.appendChild(noResultsMsg); 2860 } 2861 noResultsMsg.style.display = 'block'; 2862 } else if (noResultsMsg) { 2863 noResultsMsg.style.display = 'none'; 2864 } 2865}; 2866 2867// Toggle search mode between "this month" and "all dates" 2868window.toggleSearchMode = function(calId, namespace) { 2869 const searchMode = document.getElementById('search-mode-' + calId); 2870 const searchInput = document.getElementById('event-search-' + calId); 2871 2872 if (!searchMode) return; 2873 2874 const isAllDates = searchMode.classList.toggle('all-dates'); 2875 2876 // Update button icon and title 2877 if (isAllDates) { 2878 searchMode.innerHTML = ''; 2879 searchMode.title = 'Searching all dates'; 2880 if (searchInput) { 2881 searchInput.placeholder = 'Search all dates...'; 2882 } 2883 } else { 2884 searchMode.innerHTML = ''; 2885 searchMode.title = 'Search this month only'; 2886 if (searchInput) { 2887 searchInput.placeholder = searchInput.classList.contains('panel-search-input') ? 'Search this month...' : ' Search...'; 2888 } 2889 } 2890 2891 // Re-run search with current term 2892 if (searchInput && searchInput.value) { 2893 filterEvents(calId, searchInput.value); 2894 } else { 2895 // Clear any all-dates results 2896 const eventList = document.getElementById('eventlist-' + calId); 2897 if (eventList) { 2898 const resultsContainer = eventList.querySelector('.all-dates-results'); 2899 if (resultsContainer) { 2900 resultsContainer.remove(); 2901 } 2902 // Show normal event items 2903 eventList.querySelectorAll('.event-compact-item').forEach(item => { 2904 item.style.display = ''; 2905 }); 2906 const pastToggle = eventList.querySelector('.past-events-toggle'); 2907 if (pastToggle) pastToggle.style.display = ''; 2908 } 2909 } 2910}; 2911 2912// Search all dates via AJAX 2913window.searchAllDates = function(calId, searchTerm) { 2914 const eventList = document.getElementById('eventlist-' + calId); 2915 if (!eventList) return; 2916 2917 // Get namespace from container 2918 const container = document.getElementById(calId); 2919 const namespace = container ? (container.dataset.namespace || '') : ''; 2920 2921 // Hide normal event items 2922 eventList.querySelectorAll('.event-compact-item').forEach(item => { 2923 item.style.display = 'none'; 2924 }); 2925 const pastToggle = eventList.querySelector('.past-events-toggle'); 2926 if (pastToggle) pastToggle.style.display = 'none'; 2927 2928 // Remove old results container 2929 let resultsContainer = eventList.querySelector('.all-dates-results'); 2930 if (resultsContainer) { 2931 resultsContainer.remove(); 2932 } 2933 2934 // Create new results container 2935 resultsContainer = document.createElement('div'); 2936 resultsContainer.className = 'all-dates-results'; 2937 resultsContainer.innerHTML = '<p class="search-loading" style="text-align:center; padding:20px; color:var(--text-dim);"> Searching all dates...</p>'; 2938 eventList.appendChild(resultsContainer); 2939 2940 // Make AJAX request 2941 const params = new URLSearchParams({ 2942 call: 'plugin_calendar', 2943 action: 'search_all', 2944 search: searchTerm, 2945 namespace: namespace, 2946 _: new Date().getTime() 2947 }); 2948 2949 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 2950 method: 'POST', 2951 headers: { 2952 'Content-Type': 'application/x-www-form-urlencoded' 2953 }, 2954 body: params.toString() 2955 }) 2956 .then(r => r.json()) 2957 .then(data => { 2958 if (data.success && data.results) { 2959 if (data.results.length === 0) { 2960 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>'; 2961 } else { 2962 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>'; 2963 2964 data.results.forEach(event => { 2965 const dateObj = new Date(event.date + 'T00:00:00'); 2966 const dateDisplay = dateObj.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); 2967 const color = event.color || 'var(--text-bright, #00cc07)'; 2968 2969 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 + '\')">'; 2970 html += '<div style="width:3px; background:' + color + '; border-radius:1px; flex-shrink:0;"></div>'; 2971 html += '<div style="flex:1; min-width:0;">'; 2972 html += '<div class="event-title-compact" style="font-weight:600; color:var(--text-primary); font-size:11px;">' + escapeHtml(event.title) + '</div>'; 2973 html += '<div class="event-date-time" style="font-size:10px; color:var(--text-dim);">' + dateDisplay; 2974 if (event.time) { 2975 html += ' • ' + formatTimeRange(event.time, event.endTime); 2976 } 2977 html += '</div>'; 2978 if (event.namespace) { 2979 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>'; 2980 } 2981 html += '</div></div>'; 2982 }); 2983 2984 resultsContainer.innerHTML = html; 2985 } 2986 } else { 2987 resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim);">Search failed. Please try again.</p>'; 2988 } 2989 }) 2990 .catch(err => { 2991 console.error('Search error:', err); 2992 resultsContainer.innerHTML = '<p class="no-search-results" style="text-align:center; padding:20px; color:var(--text-dim);">Search failed. Please try again.</p>'; 2993 }); 2994}; 2995 2996// Jump to a specific date (used by search results) 2997window.jumpToDate = function(calId, date, namespace) { 2998 const parts = date.split('-'); 2999 const year = parseInt(parts[0]); 3000 const month = parseInt(parts[1]); 3001 3002 // Get container to check current month 3003 const container = document.getElementById(calId); 3004 const currentYear = container ? parseInt(container.dataset.year) : year; 3005 const currentMonth = container ? parseInt(container.dataset.month) : month; 3006 3007 // Get search elements 3008 const searchInput = document.getElementById('event-search-' + calId); 3009 const searchMode = document.getElementById('search-mode-' + calId); 3010 const searchClear = document.getElementById('search-clear-' + calId); 3011 const eventList = document.getElementById('eventlist-' + calId); 3012 3013 // Remove the all-dates results container 3014 if (eventList) { 3015 const resultsContainer = eventList.querySelector('.all-dates-results'); 3016 if (resultsContainer) { 3017 resultsContainer.remove(); 3018 } 3019 // Show normal event items again 3020 eventList.querySelectorAll('.event-compact-item').forEach(item => { 3021 item.style.display = ''; 3022 }); 3023 const pastToggle = eventList.querySelector('.past-events-toggle'); 3024 if (pastToggle) pastToggle.style.display = ''; 3025 3026 // Hide any no-results message 3027 const noResults = eventList.querySelector('.no-search-results'); 3028 if (noResults) noResults.style.display = 'none'; 3029 } 3030 3031 // Clear search input 3032 if (searchInput) { 3033 searchInput.value = ''; 3034 } 3035 3036 // Hide clear button 3037 if (searchClear) { 3038 searchClear.style.display = 'none'; 3039 } 3040 3041 // Switch back to month mode 3042 if (searchMode && searchMode.classList.contains('all-dates')) { 3043 searchMode.classList.remove('all-dates'); 3044 searchMode.innerHTML = ''; 3045 searchMode.title = 'Search this month only'; 3046 if (searchInput) { 3047 searchInput.placeholder = searchInput.classList.contains('panel-search-input') ? 'Search this month...' : ' Search...'; 3048 } 3049 } 3050 3051 // Check if we need to navigate to a different month 3052 if (year !== currentYear || month !== currentMonth) { 3053 // Navigate to the target month, then show popup 3054 navCalendar(calId, year, month, namespace); 3055 3056 // After navigation completes, show the day popup 3057 setTimeout(() => { 3058 showDayPopup(calId, date, namespace); 3059 }, 400); 3060 } else { 3061 // Same month - just show the popup 3062 showDayPopup(calId, date, namespace); 3063 } 3064}; 3065 3066// Clear event search 3067window.clearEventSearch = function(calId) { 3068 const searchInput = document.getElementById('event-search-' + calId); 3069 if (searchInput) { 3070 searchInput.value = ''; 3071 filterEvents(calId, ''); 3072 searchInput.focus(); 3073 } 3074}; 3075 3076// ============================================ 3077// PINK THEME - GLOWING PARTICLE EFFECTS 3078// ============================================ 3079 3080// Create glowing pink particle effects for pink theme 3081(function() { 3082 let pinkThemeActive = false; 3083 let trailTimer = null; 3084 let pixelTimer = null; 3085 3086 // Check if pink theme is active 3087 function checkPinkTheme() { 3088 const pinkCalendars = document.querySelectorAll('.calendar-theme-pink'); 3089 pinkThemeActive = pinkCalendars.length > 0; 3090 return pinkThemeActive; 3091 } 3092 3093 // Create trail particle 3094 function createTrailParticle(clientX, clientY) { 3095 if (!pinkThemeActive) return; 3096 3097 const trail = document.createElement('div'); 3098 trail.className = 'pink-cursor-trail'; 3099 trail.style.left = clientX + 'px'; 3100 trail.style.top = clientY + 'px'; 3101 trail.style.animation = 'cursor-trail-fade 0.5s ease-out forwards'; 3102 3103 document.body.appendChild(trail); 3104 3105 setTimeout(function() { 3106 trail.remove(); 3107 }, 500); 3108 } 3109 3110 // Create pixel sparkles 3111 function createPixelSparkles(clientX, clientY) { 3112 if (!pinkThemeActive || pixelTimer) return; 3113 3114 const pixelCount = 3 + Math.floor(Math.random() * 4); // 3-6 pixels 3115 3116 for (let i = 0; i < pixelCount; i++) { 3117 const pixel = document.createElement('div'); 3118 pixel.className = 'pink-pixel-sparkle'; 3119 3120 // Random offset from cursor 3121 const offsetX = (Math.random() - 0.5) * 30; 3122 const offsetY = (Math.random() - 0.5) * 30; 3123 3124 pixel.style.left = (clientX + offsetX) + 'px'; 3125 pixel.style.top = (clientY + offsetY) + 'px'; 3126 3127 // Random color - bright neon pinks and whites 3128 const colors = ['#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1']; 3129 const color = colors[Math.floor(Math.random() * colors.length)]; 3130 pixel.style.background = color; 3131 pixel.style.boxShadow = '0 0 2px ' + color + ', 0 0 4px ' + color + ', 0 0 6px #fff'; 3132 3133 // Random animation 3134 if (Math.random() > 0.5) { 3135 pixel.style.animation = 'pixel-twinkle 0.6s ease-out forwards'; 3136 } else { 3137 pixel.style.animation = 'pixel-float-away 0.8s ease-out forwards'; 3138 } 3139 3140 document.body.appendChild(pixel); 3141 3142 setTimeout(function() { 3143 pixel.remove(); 3144 }, 800); 3145 } 3146 3147 pixelTimer = setTimeout(function() { 3148 pixelTimer = null; 3149 }, 40); 3150 } 3151 3152 // Create explosion 3153 function createExplosion(clientX, clientY) { 3154 if (!pinkThemeActive) return; 3155 3156 const particleCount = 25; 3157 const colors = ['#ff1493', '#ff69b4', '#ff85c1', '#ffc0cb', '#fff']; 3158 3159 // Add hearts to explosion (8-12 hearts) 3160 const heartCount = 8 + Math.floor(Math.random() * 5); 3161 for (let i = 0; i < heartCount; i++) { 3162 const heart = document.createElement('div'); 3163 heart.textContent = ''; 3164 heart.style.position = 'fixed'; 3165 heart.style.left = clientX + 'px'; 3166 heart.style.top = clientY + 'px'; 3167 heart.style.pointerEvents = 'none'; 3168 heart.style.zIndex = '9999999'; 3169 heart.style.fontSize = (12 + Math.random() * 16) + 'px'; 3170 3171 // Random direction 3172 const angle = Math.random() * Math.PI * 2; 3173 const velocity = 60 + Math.random() * 80; 3174 const tx = Math.cos(angle) * velocity; 3175 const ty = Math.sin(angle) * velocity; 3176 3177 heart.style.setProperty('--tx', tx + 'px'); 3178 heart.style.setProperty('--ty', ty + 'px'); 3179 3180 const duration = 0.8 + Math.random() * 0.4; 3181 heart.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 3182 3183 document.body.appendChild(heart); 3184 3185 setTimeout(function() { 3186 heart.remove(); 3187 }, duration * 1000); 3188 } 3189 3190 // Main explosion particles 3191 for (let i = 0; i < particleCount; i++) { 3192 const particle = document.createElement('div'); 3193 particle.className = 'pink-particle'; 3194 3195 const color = colors[Math.floor(Math.random() * colors.length)]; 3196 particle.style.background = 'radial-gradient(circle, ' + color + ', transparent)'; 3197 particle.style.boxShadow = '0 0 10px ' + color + ', 0 0 20px ' + color; 3198 3199 particle.style.left = clientX + 'px'; 3200 particle.style.top = clientY + 'px'; 3201 3202 const angle = (Math.PI * 2 * i) / particleCount; 3203 const velocity = 50 + Math.random() * 100; 3204 const tx = Math.cos(angle) * velocity; 3205 const ty = Math.sin(angle) * velocity; 3206 3207 particle.style.setProperty('--tx', tx + 'px'); 3208 particle.style.setProperty('--ty', ty + 'px'); 3209 3210 const size = 4 + Math.random() * 6; 3211 particle.style.width = size + 'px'; 3212 particle.style.height = size + 'px'; 3213 3214 const duration = 0.6 + Math.random() * 0.4; 3215 particle.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 3216 3217 document.body.appendChild(particle); 3218 3219 setTimeout(function() { 3220 particle.remove(); 3221 }, duration * 1000); 3222 } 3223 3224 // Pixel sparkles 3225 const pixelSparkleCount = 40; 3226 3227 for (let i = 0; i < pixelSparkleCount; i++) { 3228 const pixel = document.createElement('div'); 3229 pixel.className = 'pink-pixel-sparkle'; 3230 3231 const pixelColors = ['#fff', '#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1']; 3232 const pixelColor = pixelColors[Math.floor(Math.random() * pixelColors.length)]; 3233 pixel.style.background = pixelColor; 3234 pixel.style.boxShadow = '0 0 3px ' + pixelColor + ', 0 0 6px ' + pixelColor + ', 0 0 9px #fff'; 3235 3236 const angle = Math.random() * Math.PI * 2; 3237 const distance = 30 + Math.random() * 80; 3238 const offsetX = Math.cos(angle) * distance; 3239 const offsetY = Math.sin(angle) * distance; 3240 3241 pixel.style.left = clientX + 'px'; 3242 pixel.style.top = clientY + 'px'; 3243 pixel.style.setProperty('--tx', offsetX + 'px'); 3244 pixel.style.setProperty('--ty', offsetY + 'px'); 3245 3246 const pixelSize = 1 + Math.random() * 2; 3247 pixel.style.width = pixelSize + 'px'; 3248 pixel.style.height = pixelSize + 'px'; 3249 3250 const duration = 0.4 + Math.random() * 0.4; 3251 if (Math.random() > 0.5) { 3252 pixel.style.animation = 'pixel-twinkle ' + duration + 's ease-out forwards'; 3253 } else { 3254 pixel.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 3255 } 3256 3257 document.body.appendChild(pixel); 3258 3259 setTimeout(function() { 3260 pixel.remove(); 3261 }, duration * 1000); 3262 } 3263 3264 // Flash 3265 const flash = document.createElement('div'); 3266 flash.style.position = 'fixed'; 3267 flash.style.left = clientX + 'px'; 3268 flash.style.top = clientY + 'px'; 3269 flash.style.width = '40px'; 3270 flash.style.height = '40px'; 3271 flash.style.borderRadius = '50%'; 3272 flash.style.background = 'radial-gradient(circle, rgba(255, 255, 255, 0.9), rgba(255, 20, 147, 0.6), transparent)'; 3273 flash.style.boxShadow = '0 0 40px #fff, 0 0 60px #ff1493, 0 0 80px #ff69b4'; 3274 flash.style.pointerEvents = 'none'; 3275 flash.style.zIndex = '9999999'; // Above everything including dialogs 3276 flash.style.transform = 'translate(-50%, -50%)'; 3277 flash.style.animation = 'cursor-trail-fade 0.3s ease-out forwards'; 3278 3279 document.body.appendChild(flash); 3280 3281 setTimeout(function() { 3282 flash.remove(); 3283 }, 300); 3284 } 3285 3286 function initPinkParticles() { 3287 if (!checkPinkTheme()) return; 3288 3289 // Use capture phase to catch events before stopPropagation 3290 document.addEventListener('mousemove', function(e) { 3291 if (!pinkThemeActive) return; 3292 3293 createTrailParticle(e.clientX, e.clientY); 3294 createPixelSparkles(e.clientX, e.clientY); 3295 }, true); // Capture phase! 3296 3297 // Throttle main trail 3298 document.addEventListener('mousemove', function(e) { 3299 if (!pinkThemeActive || trailTimer) return; 3300 3301 trailTimer = setTimeout(function() { 3302 trailTimer = null; 3303 }, 30); 3304 }, true); // Capture phase! 3305 3306 // Click explosion - use capture phase 3307 document.addEventListener('click', function(e) { 3308 if (!pinkThemeActive) return; 3309 3310 createExplosion(e.clientX, e.clientY); 3311 }, true); // Capture phase! 3312 } 3313 3314 // Initialize on load 3315 if (document.readyState === 'loading') { 3316 document.addEventListener('DOMContentLoaded', initPinkParticles); 3317 } else { 3318 initPinkParticles(); 3319 } 3320 3321 // Re-check theme if calendar is dynamically added 3322 // Must wait for document.body to exist 3323 function setupMutationObserver() { 3324 if (typeof MutationObserver !== 'undefined' && document.body) { 3325 const observer = new MutationObserver(function(mutations) { 3326 mutations.forEach(function(mutation) { 3327 if (mutation.addedNodes.length > 0) { 3328 mutation.addedNodes.forEach(function(node) { 3329 if (node.nodeType === 1 && node.classList && node.classList.contains('calendar-theme-pink')) { 3330 checkPinkTheme(); 3331 initPinkParticles(); 3332 } 3333 }); 3334 } 3335 }); 3336 }); 3337 3338 observer.observe(document.body, { 3339 childList: true, 3340 subtree: true 3341 }); 3342 } 3343 } 3344 3345 // Setup observer when DOM is ready 3346 if (document.readyState === 'loading') { 3347 document.addEventListener('DOMContentLoaded', setupMutationObserver); 3348 } else { 3349 setupMutationObserver(); 3350 } 3351})(); 3352 3353// Mobile touch event delegation for edit/delete buttons 3354// This ensures buttons work on mobile where onclick may not fire reliably 3355(function() { 3356 function handleButtonTouch(e) { 3357 const btn = e.target.closest('.event-edit-btn, .event-delete-btn, .event-action-btn'); 3358 if (!btn) return; 3359 3360 // Prevent double-firing with onclick 3361 e.preventDefault(); 3362 3363 // Small delay to show visual feedback 3364 setTimeout(function() { 3365 btn.click(); 3366 }, 10); 3367 } 3368 3369 // Use touchend for more reliable mobile handling 3370 document.addEventListener('touchend', handleButtonTouch, { passive: false }); 3371})(); 3372 3373// Static calendar navigation 3374window.navStaticCalendar = function(calId, direction) { 3375 const container = document.getElementById(calId); 3376 if (!container) return; 3377 3378 let year = parseInt(container.dataset.year); 3379 let month = parseInt(container.dataset.month); 3380 const namespace = container.dataset.namespace || ''; 3381 3382 // Calculate new month 3383 month += direction; 3384 if (month < 1) { 3385 month = 12; 3386 year--; 3387 } else if (month > 12) { 3388 month = 1; 3389 year++; 3390 } 3391 3392 // Fetch new calendar content via AJAX 3393 const params = new URLSearchParams({ 3394 call: 'plugin_calendar', 3395 action: 'get_static_calendar', 3396 year: year, 3397 month: month, 3398 namespace: namespace 3399 }); 3400 3401 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 3402 method: 'POST', 3403 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 3404 body: params.toString() 3405 }) 3406 .then(r => r.json()) 3407 .then(data => { 3408 if (data.success && data.html) { 3409 // Replace the container content 3410 container.outerHTML = data.html; 3411 } 3412 }) 3413 .catch(err => console.error('Static calendar navigation error:', err)); 3414}; 3415 3416// Print static calendar - opens print dialog with only calendar content 3417window.printStaticCalendar = function(calId) { 3418 const container = document.getElementById(calId); 3419 if (!container) return; 3420 3421 // Get the print view content 3422 const printView = container.querySelector('.static-print-view'); 3423 if (!printView) return; 3424 3425 // Create a new window for printing 3426 const printWindow = window.open('', '_blank', 'width=800,height=600'); 3427 3428 // Build print document with inline margins for maximum compatibility 3429 const printContent = ` 3430<!DOCTYPE html> 3431<html> 3432<head> 3433 <title>Calendar - ${container.dataset.year}-${String(container.dataset.month).padStart(2, '0')}</title> 3434 <style> 3435 * { margin: 0; padding: 0; box-sizing: border-box; } 3436 body { font-family: Arial, sans-serif; color: #333; background: white; } 3437 table { border-collapse: collapse; font-size: 12px; } 3438 th { background: #2c3e50; color: white; padding: 8px; text-align: left; -webkit-print-color-adjust: exact; print-color-adjust: exact; } 3439 td { padding: 6px 8px; border-bottom: 1px solid #ccc; vertical-align: top; } 3440 tr:nth-child(even) { background: #f0f0f0; -webkit-print-color-adjust: exact; print-color-adjust: exact; } 3441 .static-itinerary-important { background: #fffde7 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } 3442 .static-itinerary-date { font-weight: bold; white-space: nowrap; } 3443 .static-itinerary-time { white-space: nowrap; color: #555; } 3444 .static-itinerary-title { font-weight: 500; } 3445 .static-itinerary-desc { color: #555; font-size: 11px; } 3446 thead { display: table-header-group; } 3447 tr { page-break-inside: avoid; } 3448 h2 { font-size: 16px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 2px solid #333; } 3449 p { font-size: 12px; color: #666; margin-bottom: 15px; } 3450 </style> 3451</head> 3452<body style="margin: 0; padding: 0;"> 3453 <div style="padding: 50px 60px; margin: 0 auto; max-width: 800px;"> 3454 ${printView.innerHTML} 3455 </div> 3456 <script> 3457 window.onload = function() { 3458 setTimeout(function() { 3459 window.print(); 3460 }, 300); 3461 window.onafterprint = function() { 3462 window.close(); 3463 }; 3464 }; 3465 </script> 3466</body> 3467</html>`; 3468 3469 printWindow.document.write(printContent); 3470 printWindow.document.close(); 3471}; 3472 3473// End of calendar plugin JavaScript 3474