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 // Show colored stacked bars for each event 395 html += '<div class="event-indicators">'; 396 for (const evt of sortedEvents) { 397 const eventId = evt.id || ''; 398 const eventColor = evt.color || '#3498db'; 399 const eventTitle = evt.title || 'Event'; 400 const eventTime = evt.time || ''; 401 const originalDate = evt._original_date || dateKey; 402 const isFirstDay = evt._is_first_day !== undefined ? evt._is_first_day : true; 403 const isLastDay = evt._is_last_day !== undefined ? evt._is_last_day : true; 404 405 let barClass = !eventTime ? 'event-bar-no-time' : 'event-bar-timed'; 406 if (!isFirstDay) barClass += ' event-bar-continues'; 407 if (!isLastDay) barClass += ' event-bar-continuing'; 408 409 html += `<span class="event-bar ${barClass}" `; 410 html += `style="background: ${eventColor};" `; 411 html += `title="${escapeHtml(eventTitle)}${eventTime ? ' @ ' + eventTime : ''}" `; 412 html += `onclick="event.stopPropagation(); highlightEvent('${calId}', '${eventId}', '${originalDate}');"></span>`; 413 } 414 html += '</div>'; 415 } 416 417 html += '</td>'; 418 currentDay++; 419 } 420 } 421 html += '</tr>'; 422 } 423 424 tbody.innerHTML = html; 425 426 // Update Today button with current namespace 427 const todayBtn = container.querySelector('.cal-today-btn'); 428 if (todayBtn) { 429 todayBtn.setAttribute('onclick', `jumpToToday('${calId}', '${namespace}')`); 430 } 431 432 // Update month picker with current namespace 433 const monthPicker = container.querySelector('.calendar-month-picker'); 434 if (monthPicker) { 435 monthPicker.setAttribute('onclick', `openMonthPicker('${calId}', ${year}, ${month}, '${namespace}')`); 436 } 437 438 // Rebuild event list - server already filtered to current month 439 const eventList = container.querySelector('.event-list-compact'); 440 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 441 442 // Auto-scroll to first future event (past events will be above viewport) 443 setTimeout(() => { 444 const firstFuture = eventList.querySelector('[data-first-future="true"]'); 445 if (firstFuture) { 446 firstFuture.scrollIntoView({ behavior: 'smooth', block: 'start' }); 447 } 448 }, 100); 449 450 // Update title 451 const title = container.querySelector('#eventlist-title-' + calId); 452 title.textContent = 'Events'; 453}; 454 455// Render event list from data 456window.renderEventListFromData = function(events, calId, namespace, year, month) { 457 if (!events || Object.keys(events).length === 0) { 458 return '<p class="no-events-msg">No events this month</p>'; 459 } 460 461 // Get theme data from container 462 const container = document.getElementById(calId); 463 let themeStyles = {}; 464 if (container && container.dataset.themeStyles) { 465 try { 466 themeStyles = JSON.parse(container.dataset.themeStyles); 467 } catch (e) { 468 console.error('Failed to parse theme styles in renderEventListFromData:', e); 469 } 470 } 471 472 // Check for time conflicts 473 events = checkTimeConflicts(events, null); 474 475 let pastHtml = ''; 476 let futureHtml = ''; 477 let pastCount = 0; 478 479 const sortedDates = Object.keys(events).sort(); 480 const today = new Date(); 481 today.setHours(0, 0, 0, 0); 482 const todayStr = today.toISOString().split('T')[0]; 483 484 // Helper function to check if event is past (with 15-minute grace period) 485 const isEventPast = function(dateKey, time) { 486 // If event is on a past date, it's definitely past 487 if (dateKey < todayStr) { 488 return true; 489 } 490 491 // If event is on a future date, it's definitely not past 492 if (dateKey > todayStr) { 493 return false; 494 } 495 496 // Event is today - check time with grace period 497 if (time && time.trim() !== '') { 498 try { 499 const now = new Date(); 500 const eventDateTime = new Date(dateKey + 'T' + time); 501 502 // Add 15-minute grace period 503 const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000); 504 505 // Event is past if current time > event time + 15 minutes 506 return now > gracePeriodEnd; 507 } catch (e) { 508 // If time parsing fails, treat as future 509 return false; 510 } 511 } 512 513 // No time specified for today's event, treat as future 514 return false; 515 }; 516 517 // Filter events to only current month if year/month provided 518 const monthStart = year && month ? new Date(year, month - 1, 1) : null; 519 const monthEnd = year && month ? new Date(year, month, 0, 23, 59, 59) : null; 520 521 for (const dateKey of sortedDates) { 522 // Skip events not in current month if filtering 523 if (monthStart && monthEnd) { 524 const eventDate = new Date(dateKey + 'T00:00:00'); 525 526 if (eventDate < monthStart || eventDate > monthEnd) { 527 continue; 528 } 529 } 530 531 // Sort events within this day by time (all-day events at top) 532 const dayEvents = events[dateKey]; 533 dayEvents.sort((a, b) => { 534 const timeA = a.time && a.time.trim() !== '' ? a.time : null; 535 const timeB = b.time && b.time.trim() !== '' ? b.time : null; 536 537 // All-day events (no time) go to the TOP 538 if (timeA === null && timeB !== null) return -1; // A before B 539 if (timeA !== null && timeB === null) return 1; // A after B 540 if (timeA === null && timeB === null) return 0; // Both all-day, equal 541 542 // Both have times, sort chronologically 543 return timeA.localeCompare(timeB); 544 }); 545 546 for (const event of dayEvents) { 547 const isTask = event.isTask || false; 548 const completed = event.completed || false; 549 550 // Use helper function to determine if event is past (with grace period) 551 const isPast = isEventPast(dateKey, event.time); 552 const isPastDue = isPast && isTask && !completed; 553 554 // Determine if this goes in past section 555 const isPastOrCompleted = (isPast && (!isTask || completed)) || completed; 556 557 const eventHtml = renderEventItem(event, dateKey, calId, namespace); 558 559 if (isPastOrCompleted) { 560 pastCount++; 561 pastHtml += eventHtml; 562 } else { 563 futureHtml += eventHtml; 564 } 565 } 566 } 567 568 let html = ''; 569 570 // Add collapsible past events section if any exist 571 if (pastCount > 0) { 572 html += '<div class="past-events-section">'; 573 html += '<div class="past-events-toggle" onclick="togglePastEvents(\'' + calId + '\')">'; 574 html += '<span class="past-events-arrow" id="past-arrow-' + calId + '">▶</span> '; 575 html += '<span class="past-events-label">Past Events (' + pastCount + ')</span>'; 576 html += '</div>'; 577 html += '<div class="past-events-content" id="past-events-' + calId + '" style="display:none;">'; 578 html += pastHtml; 579 html += '</div>'; 580 html += '</div>'; 581 } else { 582 } 583 584 // Add future events 585 html += futureHtml; 586 587 588 if (!html) { 589 return '<p class="no-events-msg">No events this month</p>'; 590 } 591 592 return html; 593}; 594 595// Show day popup with events when clicking a date 596window.showDayPopup = function(calId, date, namespace) { 597 // Get events for this calendar 598 const eventsDataEl = document.getElementById('events-data-' + calId); 599 let events = {}; 600 601 if (eventsDataEl) { 602 try { 603 events = JSON.parse(eventsDataEl.textContent); 604 } catch (e) { 605 console.error('Failed to parse events data:', e); 606 } 607 } 608 609 const dayEvents = events[date] || []; 610 611 // Check for conflicts on this day 612 const dayEventsObj = {[date]: dayEvents}; 613 const checkedEvents = checkTimeConflicts(dayEventsObj, null); 614 const dayEventsWithConflicts = checkedEvents[date] || dayEvents; 615 616 // Sort events: all-day at top, then chronological by time 617 dayEventsWithConflicts.sort((a, b) => { 618 const timeA = a.time && a.time.trim() !== '' ? a.time : null; 619 const timeB = b.time && b.time.trim() !== '' ? b.time : null; 620 621 // All-day events (no time) go to the TOP 622 if (timeA === null && timeB !== null) return -1; // A before B 623 if (timeA !== null && timeB === null) return 1; // A after B 624 if (timeA === null && timeB === null) return 0; // Both all-day, equal 625 626 // Both have times, sort chronologically 627 return timeA.localeCompare(timeB); 628 }); 629 630 const dateObj = new Date(date + 'T00:00:00'); 631 const displayDate = dateObj.toLocaleDateString('en-US', { 632 weekday: 'long', 633 month: 'long', 634 day: 'numeric', 635 year: 'numeric' 636 }); 637 638 // Create popup 639 let popup = document.getElementById('day-popup-' + calId); 640 if (!popup) { 641 popup = document.createElement('div'); 642 popup.id = 'day-popup-' + calId; 643 popup.className = 'day-popup'; 644 document.body.appendChild(popup); 645 } 646 647 // Get theme styles 648 const container = document.getElementById(calId); 649 const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {}; 650 const theme = container ? container.dataset.theme : 'matrix'; 651 652 let html = '<div class="day-popup-overlay" onclick="closeDayPopup(\'' + calId + '\')"></div>'; 653 html += '<div class="day-popup-content">'; 654 html += '<div class="day-popup-header">'; 655 html += '<h4>' + displayDate + '</h4>'; 656 html += '<button class="popup-close" onclick="closeDayPopup(\'' + calId + '\')">×</button>'; 657 html += '</div>'; 658 659 html += '<div class="day-popup-body">'; 660 661 if (dayEventsWithConflicts.length === 0) { 662 html += '<p class="no-events-msg">No events on this day</p>'; 663 } else { 664 html += '<div class="popup-events-list">'; 665 dayEventsWithConflicts.forEach(event => { 666 const color = event.color || '#3498db'; 667 668 // Use individual event namespace if available (for multi-namespace support) 669 const eventNamespace = event._namespace !== undefined ? event._namespace : namespace; 670 671 // Check if this is a continuation (event started before this date) 672 const originalStartDate = event.originalStartDate || event._dateKey || date; 673 const isContinuation = originalStartDate < date; 674 675 // Convert to 12-hour format and handle time ranges 676 let displayTime = ''; 677 if (event.time) { 678 displayTime = formatTimeRange(event.time, event.endTime); 679 } 680 681 // Multi-day indicator 682 let multiDay = ''; 683 if (event.endDate && event.endDate !== date) { 684 const endObj = new Date(event.endDate + 'T00:00:00'); 685 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 686 month: 'short', 687 day: 'numeric' 688 }); 689 } 690 691 // Continuation message 692 if (isContinuation) { 693 const startObj = new Date(originalStartDate + 'T00:00:00'); 694 const startDisplay = startObj.toLocaleDateString('en-US', { 695 weekday: 'short', 696 month: 'short', 697 day: 'numeric' 698 }); 699 html += '<div class="popup-continuation-notice">↪ Continues from ' + startDisplay + '</div>'; 700 } 701 702 html += '<div class="popup-event-item">'; 703 html += '<div class="event-color-bar" style="background: ' + color + ';"></div>'; 704 html += '<div class="popup-event-content">'; 705 706 // Single line with title, time, date range, namespace, and actions 707 html += '<div class="popup-event-main-row">'; 708 html += '<div class="popup-event-info-inline">'; 709 html += '<span class="popup-event-title">' + escapeHtml(event.title) + '</span>'; 710 if (displayTime) { 711 html += '<span class="popup-event-time"> ' + displayTime + '</span>'; 712 } 713 if (multiDay) { 714 html += '<span class="popup-event-multiday">' + multiDay + '</span>'; 715 } 716 if (eventNamespace) { 717 html += '<span class="popup-event-namespace">' + escapeHtml(eventNamespace) + '</span>'; 718 } 719 720 // Add conflict warning badge if event has conflicts 721 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 722 // Build conflict list for tooltip 723 let conflictList = []; 724 event.conflictsWith.forEach(conflict => { 725 let conflictText = conflict.title; 726 if (conflict.time) { 727 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 728 } 729 conflictList.push(conflictText); 730 }); 731 732 html += '<span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 733 } 734 735 html += '</div>'; 736 html += '<div class="popup-event-actions">'; 737 html += '<button class="event-edit-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">✏️</button>'; 738 html += '<button class="event-delete-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + eventNamespace + '\'); closeDayPopup(\'' + calId + '\')">️</button>'; 739 html += '</div>'; 740 html += '</div>'; 741 742 // Description on separate line if present 743 if (event.description) { 744 html += '<div class="popup-event-desc">' + renderDescription(event.description) + '</div>'; 745 } 746 747 html += '</div></div>'; 748 }); 749 html += '</div>'; 750 } 751 752 html += '</div>'; 753 754 html += '<div class="day-popup-footer">'; 755 html += '<button class="btn-add-event" onclick="openAddEvent(\'' + calId + '\', \'' + namespace + '\', \'' + date + '\'); closeDayPopup(\'' + calId + '\')">+ Add Event</button>'; 756 html += '</div>'; 757 758 html += '</div>'; 759 760 popup.innerHTML = html; 761 popup.style.display = 'flex'; 762 763 // Propagate CSS vars from calendar container to popup (popup is outside container in DOM) 764 if (container) { 765 propagateThemeVars(calId, popup.querySelector('.day-popup-content')); 766 } 767}; 768 769// Close day popup 770window.closeDayPopup = function(calId) { 771 const popup = document.getElementById('day-popup-' + calId); 772 if (popup) { 773 popup.style.display = 'none'; 774 } 775}; 776 777// Show events for a specific day (for event list panel) 778window.showDayEvents = function(calId, date, namespace) { 779 const params = new URLSearchParams({ 780 call: 'plugin_calendar', 781 action: 'load_month', 782 year: date.split('-')[0], 783 month: parseInt(date.split('-')[1]), 784 namespace: namespace, 785 _: new Date().getTime() // Cache buster 786 }); 787 788 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 789 method: 'POST', 790 headers: { 791 'Content-Type': 'application/x-www-form-urlencoded', 792 'Cache-Control': 'no-cache, no-store, must-revalidate', 793 'Pragma': 'no-cache' 794 }, 795 body: params.toString() 796 }) 797 .then(r => r.json()) 798 .then(data => { 799 if (data.success) { 800 const eventList = document.getElementById('eventlist-' + calId); 801 const events = data.events; 802 const title = document.getElementById('eventlist-title-' + calId); 803 804 const dateObj = new Date(date + 'T00:00:00'); 805 const displayDate = dateObj.toLocaleDateString('en-US', { 806 weekday: 'short', 807 month: 'short', 808 day: 'numeric' 809 }); 810 811 title.textContent = 'Events - ' + displayDate; 812 813 // Filter events for this day 814 const dayEvents = events[date] || []; 815 816 if (dayEvents.length === 0) { 817 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>'; 818 } else { 819 let html = ''; 820 dayEvents.forEach(event => { 821 html += renderEventItem(event, date, calId, namespace); 822 }); 823 eventList.innerHTML = html; 824 } 825 } 826 }) 827 .catch(err => console.error('Error:', err)); 828}; 829 830// Render a single event item 831window.renderEventItem = function(event, date, calId, namespace) { 832 // Get theme data from container 833 const container = document.getElementById(calId); 834 let themeStyles = {}; 835 if (container && container.dataset.themeStyles) { 836 try { 837 themeStyles = JSON.parse(container.dataset.themeStyles); 838 } catch (e) { 839 console.error('Failed to parse theme styles:', e); 840 } 841 } 842 843 // Check if this event is in the past or today (with 15-minute grace period) 844 const today = new Date(); 845 today.setHours(0, 0, 0, 0); 846 const todayStr = today.toISOString().split('T')[0]; 847 const eventDate = new Date(date + 'T00:00:00'); 848 849 // Helper to determine if event is past with grace period 850 let isPast; 851 if (date < todayStr) { 852 isPast = true; // Past date 853 } else if (date > todayStr) { 854 isPast = false; // Future date 855 } else { 856 // Today - check time with grace period 857 if (event.time && event.time.trim() !== '') { 858 try { 859 const now = new Date(); 860 const eventDateTime = new Date(date + 'T' + event.time); 861 const gracePeriodEnd = new Date(eventDateTime.getTime() + 15 * 60 * 1000); 862 isPast = now > gracePeriodEnd; 863 } catch (e) { 864 isPast = false; 865 } 866 } else { 867 isPast = false; // No time, treat as future 868 } 869 } 870 871 const isToday = eventDate.getTime() === today.getTime(); 872 873 // Format date display with day of week 874 const displayDateKey = event.originalStartDate || date; 875 const dateObj = new Date(displayDateKey + 'T00:00:00'); 876 const displayDate = dateObj.toLocaleDateString('en-US', { 877 weekday: 'short', 878 month: 'short', 879 day: 'numeric' 880 }); 881 882 // Convert to 12-hour format and handle time ranges 883 let displayTime = ''; 884 if (event.time) { 885 displayTime = formatTimeRange(event.time, event.endTime); 886 } 887 888 // Multi-day indicator 889 let multiDay = ''; 890 if (event.endDate && event.endDate !== displayDateKey) { 891 const endObj = new Date(event.endDate + 'T00:00:00'); 892 multiDay = ' → ' + endObj.toLocaleDateString('en-US', { 893 weekday: 'short', 894 month: 'short', 895 day: 'numeric' 896 }); 897 } 898 899 const completedClass = event.completed ? ' event-completed' : ''; 900 const isTask = event.isTask || false; 901 const completed = event.completed || false; 902 const isPastDue = isPast && isTask && !completed; 903 const pastClass = (isPast && !isPastDue) ? ' event-past' : ''; 904 const pastDueClass = isPastDue ? ' event-pastdue' : ''; 905 const color = event.color || '#3498db'; 906 907 // Only inline style needed: border-left-color for event color indicator 908 let html = '<div class="event-compact-item' + completedClass + pastClass + pastDueClass + '" data-event-id="' + event.id + '" data-date="' + date + '" style="border-left-color: ' + color + ';" onclick="' + (isPast && !isPastDue ? 'togglePastEventExpand(this)' : '') + '">'; 909 910 html += '<div class="event-info">'; 911 html += '<div class="event-title-row">'; 912 html += '<span class="event-title-compact">' + escapeHtml(event.title) + '</span>'; 913 html += '</div>'; 914 915 // Show meta and description for non-past events AND past due tasks 916 if (!isPast || isPastDue) { 917 html += '<div class="event-meta-compact">'; 918 html += '<span class="event-date-time">' + displayDate + multiDay; 919 if (displayTime) { 920 html += ' • ' + displayTime; 921 } 922 // Add PAST DUE or TODAY badge 923 if (isPastDue) { 924 html += ' <span class="event-pastdue-badge">PAST DUE</span>'; 925 } else if (isToday) { 926 html += ' <span class="event-today-badge">TODAY</span>'; 927 } 928 // Add namespace badge 929 let eventNamespace = event.namespace || ''; 930 if (!eventNamespace && event._namespace !== undefined) { 931 eventNamespace = event._namespace; 932 } 933 if (eventNamespace) { 934 html += ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' + calId + '\', \'' + escapeHtml(eventNamespace) + '\')" title="Click to filter by this namespace">' + escapeHtml(eventNamespace) + '</span>'; 935 } 936 // Add conflict warning if event has time conflicts 937 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 938 let conflictList = []; 939 event.conflictsWith.forEach(conflict => { 940 let conflictText = conflict.title; 941 if (conflict.time) { 942 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 943 } 944 conflictList.push(conflictText); 945 }); 946 947 html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 948 } 949 html += '</span>'; 950 html += '</div>'; 951 952 if (event.description) { 953 html += '<div class="event-desc-compact">' + renderDescription(event.description) + '</div>'; 954 } 955 } else { 956 // For past events (not past due), store data in hidden divs for expand/collapse 957 html += '<div class="event-meta-compact" style="display: none;">'; 958 html += '<span class="event-date-time">' + displayDate + multiDay; 959 if (displayTime) { 960 html += ' • ' + displayTime; 961 } 962 // Add namespace badge for past events too 963 let eventNamespace = event.namespace || ''; 964 if (!eventNamespace && event._namespace !== undefined) { 965 eventNamespace = event._namespace; 966 } 967 if (eventNamespace) { 968 html += ' <span class="event-namespace-badge" onclick="filterCalendarByNamespace(\'' + calId + '\', \'' + escapeHtml(eventNamespace) + '\')" title="Click to filter by this namespace">' + escapeHtml(eventNamespace) + '</span>'; 969 } 970 // Add conflict warning for past events too 971 if (event.hasConflict && event.conflictsWith && event.conflictsWith.length > 0) { 972 let conflictList = []; 973 event.conflictsWith.forEach(conflict => { 974 let conflictText = conflict.title; 975 if (conflict.time) { 976 conflictText += ' (' + formatTimeRange(conflict.time, conflict.endTime) + ')'; 977 } 978 conflictList.push(conflictText); 979 }); 980 981 html += ' <span class="event-conflict-badge" data-conflicts="' + btoa(unescape(encodeURIComponent(JSON.stringify(conflictList)))) + '" onmouseenter="showConflictTooltip(this)" onmouseleave="hideConflictTooltip()">⚠️ ' + event.conflictsWith.length + '</span>'; 982 } 983 html += '</span>'; 984 html += '</div>'; 985 986 if (event.description) { 987 html += '<div class="event-desc-compact" style="display: none;">' + renderDescription(event.description) + '</div>'; 988 } 989 } 990 991 html += '</div>'; // event-info 992 993 // Use stored namespace from event, fallback to _namespace, then passed namespace 994 let buttonNamespace = event.namespace || ''; 995 if (!buttonNamespace && event._namespace !== undefined) { 996 buttonNamespace = event._namespace; 997 } 998 if (!buttonNamespace) { 999 buttonNamespace = namespace; 1000 } 1001 1002 html += '<div class="event-actions-compact">'; 1003 html += '<button class="event-action-btn" onclick="deleteEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">️</button>'; 1004 html += '<button class="event-action-btn" onclick="editEvent(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\')">✏️</button>'; 1005 html += '</div>'; 1006 1007 // Checkbox for tasks - ON THE FAR RIGHT 1008 if (isTask) { 1009 const checked = completed ? 'checked' : ''; 1010 html += '<input type="checkbox" class="task-checkbox" ' + checked + ' onclick="toggleTaskComplete(\'' + calId + '\', \'' + event.id + '\', \'' + date + '\', \'' + buttonNamespace + '\', this.checked)">'; 1011 } 1012 1013 html += '</div>'; 1014 1015 return html; 1016}; 1017 1018// Render description with rich content support 1019window.renderDescription = function(description) { 1020 if (!description) return ''; 1021 1022 // First, convert DokuWiki/Markdown syntax to placeholder tokens (before escaping) 1023 // Use a format that won't be affected by HTML escaping: \x00TOKEN_N\x00 1024 1025 let rendered = description; 1026 const tokens = []; 1027 let tokenIndex = 0; 1028 1029 // Convert DokuWiki image syntax {{image.jpg}} to tokens 1030 rendered = rendered.replace(/\{\{([^}|]+?)(?:\|([^}]+))?\}\}/g, function(match, imagePath, alt) { 1031 imagePath = imagePath.trim(); 1032 alt = alt ? alt.trim() : ''; 1033 1034 let imageHtml; 1035 // Handle external URLs 1036 if (imagePath.match(/^https?:\/\//)) { 1037 imageHtml = '<img src="' + imagePath + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 1038 } else { 1039 // Handle internal DokuWiki images 1040 const imageUrl = DOKU_BASE + 'lib/exe/fetch.php?media=' + encodeURIComponent(imagePath); 1041 imageHtml = '<img src="' + imageUrl + '" alt="' + escapeHtml(alt) + '" class="event-image" />'; 1042 } 1043 1044 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1045 tokens[tokenIndex] = imageHtml; 1046 tokenIndex++; 1047 return token; 1048 }); 1049 1050 // Convert DokuWiki link syntax [[link|text]] to tokens 1051 rendered = rendered.replace(/\[\[([^|\]]+?)(?:\|([^\]]+))?\]\]/g, function(match, link, text) { 1052 link = link.trim(); 1053 text = text ? text.trim() : link; 1054 1055 let linkHtml; 1056 // Handle external URLs 1057 if (link.match(/^https?:\/\//)) { 1058 linkHtml = '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 1059 } else { 1060 // Handle internal DokuWiki links with section anchors 1061 const hashIndex = link.indexOf('#'); 1062 let pagePart = link; 1063 let sectionPart = ''; 1064 1065 if (hashIndex !== -1) { 1066 pagePart = link.substring(0, hashIndex); 1067 sectionPart = link.substring(hashIndex); // Includes the # 1068 } 1069 1070 const wikiUrl = DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pagePart) + sectionPart; 1071 linkHtml = '<a href="' + wikiUrl + '">' + escapeHtml(text) + '</a>'; 1072 } 1073 1074 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1075 tokens[tokenIndex] = linkHtml; 1076 tokenIndex++; 1077 return token; 1078 }); 1079 1080 // Convert markdown-style links [text](url) to tokens 1081 rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) { 1082 text = text.trim(); 1083 url = url.trim(); 1084 1085 let linkHtml; 1086 if (url.match(/^https?:\/\//)) { 1087 linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + '</a>'; 1088 } else { 1089 linkHtml = '<a href="' + escapeHtml(url) + '">' + escapeHtml(text) + '</a>'; 1090 } 1091 1092 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1093 tokens[tokenIndex] = linkHtml; 1094 tokenIndex++; 1095 return token; 1096 }); 1097 1098 // Convert plain URLs to tokens 1099 rendered = rendered.replace(/(https?:\/\/[^\s<]+)/g, function(match, url) { 1100 const linkHtml = '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(url) + '</a>'; 1101 const token = '\x00TOKEN' + tokenIndex + '\x00'; 1102 tokens[tokenIndex] = linkHtml; 1103 tokenIndex++; 1104 return token; 1105 }); 1106 1107 // NOW escape the remaining text (tokens are protected with null bytes) 1108 rendered = escapeHtml(rendered); 1109 1110 // Convert newlines to <br> 1111 rendered = rendered.replace(/\n/g, '<br>'); 1112 1113 // DokuWiki text formatting (on escaped text) 1114 // Bold: **text** or __text__ 1115 rendered = rendered.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 1116 rendered = rendered.replace(/__(.+?)__/g, '<strong>$1</strong>'); 1117 1118 // Italic: //text// 1119 rendered = rendered.replace(/\/\/(.+?)\/\//g, '<em>$1</em>'); 1120 1121 // Strikethrough: <del>text</del> 1122 rendered = rendered.replace(/<del>(.+?)<\/del>/g, '<del>$1</del>'); 1123 1124 // Monospace: ''text'' 1125 rendered = rendered.replace(/''(.+?)''/g, '<code>$1</code>'); 1126 1127 // Subscript: <sub>text</sub> 1128 rendered = rendered.replace(/<sub>(.+?)<\/sub>/g, '<sub>$1</sub>'); 1129 1130 // Superscript: <sup>text</sup> 1131 rendered = rendered.replace(/<sup>(.+?)<\/sup>/g, '<sup>$1</sup>'); 1132 1133 // Restore tokens (replace with actual HTML) 1134 for (let i = 0; i < tokens.length; i++) { 1135 const tokenPattern = new RegExp('\x00TOKEN' + i + '\x00', 'g'); 1136 rendered = rendered.replace(tokenPattern, tokens[i]); 1137 } 1138 1139 return rendered; 1140} 1141 1142// Open add event dialog 1143window.openAddEvent = function(calId, namespace, date) { 1144 const dialog = document.getElementById('dialog-' + calId); 1145 const form = document.getElementById('eventform-' + calId); 1146 const title = document.getElementById('dialog-title-' + calId); 1147 const dateField = document.getElementById('event-date-' + calId); 1148 1149 if (!dateField) { 1150 console.error('Date field not found! ID: event-date-' + calId); 1151 return; 1152 } 1153 1154 // Check if there's a filtered namespace active (only for regular calendars) 1155 const calendar = document.getElementById(calId); 1156 const filteredNamespace = calendar ? calendar.dataset.filteredNamespace : null; 1157 1158 // Use filtered namespace if available, otherwise use the passed namespace 1159 const effectiveNamespace = filteredNamespace || namespace; 1160 1161 1162 // Reset form 1163 form.reset(); 1164 document.getElementById('event-id-' + calId).value = ''; 1165 1166 // Store the effective namespace in a hidden field or data attribute 1167 form.dataset.effectiveNamespace = effectiveNamespace; 1168 1169 // Set namespace dropdown to effective namespace 1170 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1171 if (namespaceSelect) { 1172 if (effectiveNamespace && effectiveNamespace !== '*' && effectiveNamespace.indexOf(';') === -1) { 1173 // Set to specific namespace if not wildcard or multi-namespace 1174 namespaceSelect.value = effectiveNamespace; 1175 } else { 1176 // Default to empty (default namespace) for wildcard/multi views 1177 namespaceSelect.value = ''; 1178 } 1179 } 1180 1181 // Clear event namespace from previous edits 1182 delete form.dataset.eventNamespace; 1183 1184 // Set date - use local date, not UTC 1185 let defaultDate = date; 1186 if (!defaultDate) { 1187 // Get the currently displayed month from the calendar container 1188 const container = document.getElementById(calId); 1189 const displayedYear = parseInt(container.getAttribute('data-year')); 1190 const displayedMonth = parseInt(container.getAttribute('data-month')); 1191 1192 1193 if (displayedYear && displayedMonth) { 1194 // Use first day of the displayed month 1195 const year = displayedYear; 1196 const month = String(displayedMonth).padStart(2, '0'); 1197 defaultDate = `${year}-${month}-01`; 1198 } else { 1199 // Fallback to today if attributes not found 1200 const today = new Date(); 1201 const year = today.getFullYear(); 1202 const month = String(today.getMonth() + 1).padStart(2, '0'); 1203 const day = String(today.getDate()).padStart(2, '0'); 1204 defaultDate = `${year}-${month}-${day}`; 1205 } 1206 } 1207 dateField.value = defaultDate; 1208 dateField.removeAttribute('data-original-date'); 1209 1210 // Also set the end date field to the same default (user can change it) 1211 const endDateField = document.getElementById('event-end-date-' + calId); 1212 if (endDateField) { 1213 endDateField.value = ''; // Empty by default (single-day event) 1214 // Set min attribute to help the date picker open on the right month 1215 endDateField.setAttribute('min', defaultDate); 1216 } 1217 1218 // Set default color 1219 document.getElementById('event-color-' + calId).value = '#3498db'; 1220 1221 // Initialize end time dropdown (disabled by default since no start time set) 1222 const endTimeField = document.getElementById('event-end-time-' + calId); 1223 if (endTimeField) { 1224 endTimeField.disabled = true; 1225 endTimeField.value = ''; 1226 } 1227 1228 // Initialize namespace search 1229 initNamespaceSearch(calId); 1230 1231 // Set title 1232 title.textContent = 'Add Event'; 1233 1234 // Show dialog 1235 dialog.style.display = 'flex'; 1236 1237 // Propagate CSS vars to dialog (position:fixed can break inheritance in some templates) 1238 propagateThemeVars(calId, dialog); 1239 1240 // Focus title field 1241 setTimeout(() => { 1242 const titleField = document.getElementById('event-title-' + calId); 1243 if (titleField) titleField.focus(); 1244 }, 100); 1245}; 1246 1247// Edit event 1248window.editEvent = function(calId, eventId, date, namespace) { 1249 const params = new URLSearchParams({ 1250 call: 'plugin_calendar', 1251 action: 'get_event', 1252 namespace: namespace, 1253 date: date, 1254 eventId: eventId 1255 }); 1256 1257 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1258 method: 'POST', 1259 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1260 body: params.toString() 1261 }) 1262 .then(r => r.json()) 1263 .then(data => { 1264 if (data.success && data.event) { 1265 const event = data.event; 1266 const dialog = document.getElementById('dialog-' + calId); 1267 const title = document.getElementById('dialog-title-' + calId); 1268 const dateField = document.getElementById('event-date-' + calId); 1269 const form = document.getElementById('eventform-' + calId); 1270 1271 if (!dateField) { 1272 console.error('Date field not found when editing!'); 1273 return; 1274 } 1275 1276 // Store the event's actual namespace for saving (important for namespace=* views) 1277 if (event.namespace !== undefined) { 1278 form.dataset.eventNamespace = event.namespace; 1279 } 1280 1281 // Populate form 1282 document.getElementById('event-id-' + calId).value = event.id; 1283 dateField.value = date; 1284 dateField.setAttribute('data-original-date', date); 1285 1286 const endDateField = document.getElementById('event-end-date-' + calId); 1287 endDateField.value = event.endDate || ''; 1288 // Set min attribute to help date picker open on the start date's month 1289 endDateField.setAttribute('min', date); 1290 1291 document.getElementById('event-title-' + calId).value = event.title; 1292 document.getElementById('event-time-' + calId).value = event.time || ''; 1293 document.getElementById('event-end-time-' + calId).value = event.endTime || ''; 1294 document.getElementById('event-color-' + calId).value = event.color || '#3498db'; 1295 document.getElementById('event-desc-' + calId).value = event.description || ''; 1296 document.getElementById('event-is-task-' + calId).checked = event.isTask || false; 1297 1298 // Update end time options based on start time 1299 if (event.time) { 1300 updateEndTimeOptions(calId); 1301 } 1302 1303 // Initialize namespace search 1304 initNamespaceSearch(calId); 1305 1306 // Set namespace fields if available 1307 const namespaceHidden = document.getElementById('event-namespace-' + calId); 1308 const namespaceSearch = document.getElementById('event-namespace-search-' + calId); 1309 if (namespaceHidden && event.namespace !== undefined) { 1310 // Set the hidden input (this is what gets submitted) 1311 namespaceHidden.value = event.namespace || ''; 1312 // Set the search input to display the namespace 1313 if (namespaceSearch) { 1314 namespaceSearch.value = event.namespace || '(default)'; 1315 } 1316 console.log('Set namespace for editing:', event.namespace, 'Hidden value:', namespaceHidden.value); 1317 } else { 1318 // No namespace on event, set to default 1319 if (namespaceHidden) { 1320 namespaceHidden.value = ''; 1321 } 1322 if (namespaceSearch) { 1323 namespaceSearch.value = '(default)'; 1324 } 1325 console.log('No namespace on event, using default'); 1326 } 1327 1328 title.textContent = 'Edit Event'; 1329 dialog.style.display = 'flex'; 1330 1331 // Propagate CSS vars to dialog 1332 propagateThemeVars(calId, dialog); 1333 } 1334 }) 1335 .catch(err => console.error('Error editing event:', err)); 1336}; 1337 1338// Delete event 1339window.deleteEvent = function(calId, eventId, date, namespace) { 1340 if (!confirm('Delete this event?')) return; 1341 1342 const params = new URLSearchParams({ 1343 call: 'plugin_calendar', 1344 action: 'delete_event', 1345 namespace: namespace, 1346 date: date, 1347 eventId: eventId 1348 }); 1349 1350 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1351 method: 'POST', 1352 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1353 body: params.toString() 1354 }) 1355 .then(r => r.json()) 1356 .then(data => { 1357 if (data.success) { 1358 // Extract year and month from date 1359 const [year, month] = date.split('-').map(Number); 1360 1361 // Reload calendar data via AJAX 1362 reloadCalendarData(calId, year, month, namespace); 1363 } 1364 }) 1365 .catch(err => console.error('Error:', err)); 1366}; 1367 1368// Save event (add or edit) 1369window.saveEventCompact = function(calId, namespace) { 1370 const form = document.getElementById('eventform-' + calId); 1371 1372 // Get namespace from dropdown - this is what the user selected 1373 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1374 const selectedNamespace = namespaceSelect ? namespaceSelect.value : ''; 1375 1376 // ALWAYS use what the user selected in the dropdown 1377 // This allows changing namespace when editing 1378 const finalNamespace = selectedNamespace; 1379 1380 const eventId = document.getElementById('event-id-' + calId).value; 1381 1382 // eventNamespace is the ORIGINAL namespace (only used for finding/deleting old event) 1383 const originalNamespace = form.dataset.eventNamespace; 1384 1385 1386 const dateInput = document.getElementById('event-date-' + calId); 1387 const date = dateInput.value; 1388 const oldDate = dateInput.getAttribute('data-original-date') || date; 1389 const endDate = document.getElementById('event-end-date-' + calId).value; 1390 const title = document.getElementById('event-title-' + calId).value; 1391 const time = document.getElementById('event-time-' + calId).value; 1392 const endTime = document.getElementById('event-end-time-' + calId).value; 1393 const colorSelect = document.getElementById('event-color-' + calId); 1394 let color = colorSelect.value; 1395 1396 // Handle custom color 1397 if (color === 'custom') { 1398 color = colorSelect.dataset.customColor || document.getElementById('event-color-custom-' + calId).value; 1399 } 1400 1401 const description = document.getElementById('event-desc-' + calId).value; 1402 const isTask = document.getElementById('event-is-task-' + calId).checked; 1403 const completed = false; // New tasks are not completed 1404 const isRecurring = document.getElementById('event-recurring-' + calId).checked; 1405 const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value; 1406 const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value; 1407 1408 if (!title) { 1409 alert('Please enter a title'); 1410 return; 1411 } 1412 1413 if (!date) { 1414 alert('Please select a date'); 1415 return; 1416 } 1417 1418 const params = new URLSearchParams({ 1419 call: 'plugin_calendar', 1420 action: 'save_event', 1421 namespace: finalNamespace, 1422 eventId: eventId, 1423 date: date, 1424 oldDate: oldDate, 1425 endDate: endDate, 1426 title: title, 1427 time: time, 1428 endTime: endTime, 1429 color: color, 1430 description: description, 1431 isTask: isTask ? '1' : '0', 1432 completed: completed ? '1' : '0', 1433 isRecurring: isRecurring ? '1' : '0', 1434 recurrenceType: recurrenceType, 1435 recurrenceEnd: recurrenceEnd 1436 }); 1437 1438 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1439 method: 'POST', 1440 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1441 body: params.toString() 1442 }) 1443 .then(r => r.json()) 1444 .then(data => { 1445 if (data.success) { 1446 closeEventDialog(calId); 1447 1448 // For recurring events, do a full page reload to show all occurrences 1449 if (isRecurring) { 1450 location.reload(); 1451 return; 1452 } 1453 1454 // Extract year and month from the NEW date (in case date was changed) 1455 const [year, month] = date.split('-').map(Number); 1456 1457 // Reload calendar data via AJAX to the month of the event 1458 reloadCalendarData(calId, year, month, namespace); 1459 } else { 1460 alert('Error: ' + (data.error || 'Unknown error')); 1461 } 1462 }) 1463 .catch(err => { 1464 console.error('Error:', err); 1465 alert('Error saving event'); 1466 }); 1467}; 1468 1469// Reload calendar data without page refresh 1470window.reloadCalendarData = function(calId, year, month, namespace) { 1471 const params = new URLSearchParams({ 1472 call: 'plugin_calendar', 1473 action: 'load_month', 1474 year: year, 1475 month: month, 1476 namespace: namespace, 1477 _: new Date().getTime() // Cache buster 1478 }); 1479 1480 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1481 method: 'POST', 1482 headers: { 1483 'Content-Type': 'application/x-www-form-urlencoded', 1484 'Cache-Control': 'no-cache, no-store, must-revalidate', 1485 'Pragma': 'no-cache' 1486 }, 1487 body: params.toString() 1488 }) 1489 .then(r => r.json()) 1490 .then(data => { 1491 if (data.success) { 1492 const container = document.getElementById(calId); 1493 1494 // Check if this is a full calendar or just event panel 1495 if (container.classList.contains('calendar-compact-container')) { 1496 rebuildCalendar(calId, data.year, data.month, data.events, namespace); 1497 } else if (container.classList.contains('event-panel-standalone')) { 1498 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1499 } 1500 } 1501 }) 1502 .catch(err => console.error('Error:', err)); 1503}; 1504 1505// Close event dialog 1506window.closeEventDialog = function(calId) { 1507 const dialog = document.getElementById('dialog-' + calId); 1508 dialog.style.display = 'none'; 1509}; 1510 1511// Escape HTML 1512window.escapeHtml = function(text) { 1513 const div = document.createElement('div'); 1514 div.textContent = text; 1515 return div.innerHTML; 1516}; 1517 1518// Highlight event when clicking on bar in calendar 1519window.highlightEvent = function(calId, eventId, date) { 1520 console.log('Highlighting event:', calId, eventId, date); 1521 1522 // Find the event item in the event list 1523 const eventList = document.querySelector('#' + calId + ' .event-list-compact'); 1524 if (!eventList) { 1525 console.log('Event list not found'); 1526 return; 1527 } 1528 1529 const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]'); 1530 if (!eventItem) { 1531 console.log('Event item not found'); 1532 return; 1533 } 1534 1535 console.log('Found event item:', eventItem); 1536 1537 // Get theme 1538 const container = document.getElementById(calId); 1539 const theme = container ? container.dataset.theme : 'matrix'; 1540 const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {}; 1541 1542 console.log('Theme:', theme); 1543 1544 // Theme-specific highlight colors 1545 let highlightBg, highlightShadow; 1546 if (theme === 'matrix') { 1547 highlightBg = '#1a3d1a'; // Darker green 1548 highlightShadow = '0 0 20px rgba(0, 204, 7, 0.8), 0 0 40px rgba(0, 204, 7, 0.4)'; 1549 } else if (theme === 'purple') { 1550 highlightBg = '#3d2b4d'; // Darker purple 1551 highlightShadow = '0 0 20px rgba(155, 89, 182, 0.8), 0 0 40px rgba(155, 89, 182, 0.4)'; 1552 } else if (theme === 'professional') { 1553 highlightBg = '#e3f2fd'; // Light blue 1554 highlightShadow = '0 0 20px rgba(74, 144, 226, 0.4)'; 1555 } else if (theme === 'pink') { 1556 highlightBg = '#3d2030'; // Darker pink 1557 highlightShadow = '0 0 20px rgba(255, 20, 147, 0.8), 0 0 40px rgba(255, 20, 147, 0.4)'; 1558 } else if (theme === 'wiki') { 1559 highlightBg = '#dce9f5'; // Light blue highlight 1560 highlightShadow = '0 0 20px rgba(43, 115, 183, 0.4)'; 1561 } 1562 1563 console.log('Highlight colors:', highlightBg, highlightShadow); 1564 1565 // Store original styles 1566 const originalBg = eventItem.style.background; 1567 const originalShadow = eventItem.style.boxShadow; 1568 1569 // Remove previous highlights (restore their original styles) 1570 const previousHighlights = eventList.querySelectorAll('.event-highlighted'); 1571 previousHighlights.forEach(el => { 1572 el.classList.remove('event-highlighted'); 1573 }); 1574 1575 // Add highlight class and apply theme-aware glow 1576 eventItem.classList.add('event-highlighted'); 1577 1578 // Set CSS properties directly 1579 eventItem.style.setProperty('background', highlightBg, 'important'); 1580 eventItem.style.setProperty('box-shadow', highlightShadow, 'important'); 1581 eventItem.style.setProperty('transition', 'all 0.3s ease-in-out', 'important'); 1582 1583 console.log('Applied highlight styles'); 1584 1585 // Scroll to event 1586 eventItem.scrollIntoView({ 1587 behavior: 'smooth', 1588 block: 'nearest', 1589 inline: 'nearest' 1590 }); 1591 1592 // Remove highlight after 3 seconds and restore original styles 1593 setTimeout(() => { 1594 console.log('Removing highlight'); 1595 eventItem.classList.remove('event-highlighted'); 1596 eventItem.style.setProperty('background', originalBg); 1597 eventItem.style.setProperty('box-shadow', originalShadow); 1598 eventItem.style.setProperty('transition', ''); 1599 }, 3000); 1600}; 1601 1602// Toggle recurring event options 1603window.toggleRecurringOptions = function(calId) { 1604 const checkbox = document.getElementById('event-recurring-' + calId); 1605 const options = document.getElementById('recurring-options-' + calId); 1606 1607 if (checkbox && options) { 1608 options.style.display = checkbox.checked ? 'block' : 'none'; 1609 } 1610}; 1611 1612// ============================================================ 1613// Document-level event delegation (guarded - only attach once) 1614// These use event delegation so they work for AJAX-rebuilt content. 1615// ============================================================ 1616if (!window._calendarDelegationInit) { 1617 window._calendarDelegationInit = true; 1618 1619 // ESC closes dialogs, popups, tooltips 1620 document.addEventListener('keydown', function(e) { 1621 if (e.key === 'Escape') { 1622 document.querySelectorAll('.event-dialog-compact').forEach(function(d) { 1623 if (d.style.display === 'flex') d.style.display = 'none'; 1624 }); 1625 document.querySelectorAll('.day-popup').forEach(function(p) { 1626 p.style.display = 'none'; 1627 }); 1628 hideConflictTooltip(); 1629 } 1630 }); 1631 1632 // Conflict tooltip delegation (capture phase for mouseenter/leave) 1633 document.addEventListener('mouseenter', function(e) { 1634 if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) { 1635 showConflictTooltip(e.target); 1636 } 1637 }, true); 1638 1639 document.addEventListener('mouseleave', function(e) { 1640 if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) { 1641 hideConflictTooltip(); 1642 } 1643 }, true); 1644} // end delegation guard 1645 1646// Event panel navigation 1647window.navEventPanel = function(calId, year, month, namespace) { 1648 const params = new URLSearchParams({ 1649 call: 'plugin_calendar', 1650 action: 'load_month', 1651 year: year, 1652 month: month, 1653 namespace: namespace, 1654 _: new Date().getTime() // Cache buster 1655 }); 1656 1657 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1658 method: 'POST', 1659 headers: { 1660 'Content-Type': 'application/x-www-form-urlencoded', 1661 'Cache-Control': 'no-cache, no-store, must-revalidate', 1662 'Pragma': 'no-cache' 1663 }, 1664 body: params.toString() 1665 }) 1666 .then(r => r.json()) 1667 .then(data => { 1668 if (data.success) { 1669 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1670 } 1671 }) 1672 .catch(err => console.error('Error:', err)); 1673}; 1674 1675// Rebuild event panel only 1676window.rebuildEventPanel = function(calId, year, month, events, namespace) { 1677 const container = document.getElementById(calId); 1678 const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 1679 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 1680 1681 // Update month title in new compact header 1682 const monthTitle = container.querySelector('.panel-month-title'); 1683 if (monthTitle) { 1684 monthTitle.textContent = monthNames[month - 1] + ' ' + year; 1685 monthTitle.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1686 monthTitle.setAttribute('title', 'Click to jump to month'); 1687 } 1688 1689 // Fallback: Update old header format if exists 1690 const oldHeader = container.querySelector('.panel-standalone-header h3, .calendar-month-picker'); 1691 if (oldHeader && !monthTitle) { 1692 oldHeader.textContent = monthNames[month - 1] + ' ' + year + ' Events'; 1693 oldHeader.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1694 } 1695 1696 // Update nav buttons 1697 let prevMonth = month - 1; 1698 let prevYear = year; 1699 if (prevMonth < 1) { 1700 prevMonth = 12; 1701 prevYear--; 1702 } 1703 1704 let nextMonth = month + 1; 1705 let nextYear = year; 1706 if (nextMonth > 12) { 1707 nextMonth = 1; 1708 nextYear++; 1709 } 1710 1711 // Update new compact nav buttons 1712 const navBtns = container.querySelectorAll('.panel-nav-btn'); 1713 if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 1714 if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 1715 1716 // Fallback for old nav buttons 1717 const oldNavBtns = container.querySelectorAll('.cal-nav-btn'); 1718 if (oldNavBtns.length > 0 && navBtns.length === 0) { 1719 if (oldNavBtns[0]) oldNavBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 1720 if (oldNavBtns[1]) oldNavBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 1721 } 1722 1723 // Update Today button (works for both old and new) 1724 const todayBtn = container.querySelector('.panel-today-btn, .cal-today-btn, .cal-today-btn-compact'); 1725 if (todayBtn) { 1726 todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`); 1727 } 1728 1729 // Rebuild event list 1730 const eventList = container.querySelector('.event-list-compact'); 1731 if (eventList) { 1732 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 1733 } 1734}; 1735 1736// Open add event for panel 1737window.openAddEventPanel = function(calId, namespace) { 1738 const today = new Date(); 1739 const year = today.getFullYear(); 1740 const month = String(today.getMonth() + 1).padStart(2, '0'); 1741 const day = String(today.getDate()).padStart(2, '0'); 1742 const localDate = `${year}-${month}-${day}`; 1743 openAddEvent(calId, namespace, localDate); 1744}; 1745 1746// Toggle task completion 1747window.toggleTaskComplete = function(calId, eventId, date, namespace, completed) { 1748 const params = new URLSearchParams({ 1749 call: 'plugin_calendar', 1750 action: 'toggle_task', 1751 namespace: namespace, 1752 date: date, 1753 eventId: eventId, 1754 completed: completed ? '1' : '0' 1755 }); 1756 1757 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1758 method: 'POST', 1759 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1760 body: params.toString() 1761 }) 1762 .then(r => r.json()) 1763 .then(data => { 1764 if (data.success) { 1765 const [year, month] = date.split('-').map(Number); 1766 reloadCalendarData(calId, year, month, namespace); 1767 } 1768 }) 1769 .catch(err => console.error('Error toggling task:', err)); 1770}; 1771 1772// Make dialog draggable 1773window.makeDialogDraggable = function(calId) { 1774 const dialog = document.getElementById('dialog-content-' + calId); 1775 const handle = document.getElementById('drag-handle-' + calId); 1776 1777 if (!dialog || !handle) return; 1778 1779 let isDragging = false; 1780 let currentX; 1781 let currentY; 1782 let initialX; 1783 let initialY; 1784 let xOffset = 0; 1785 let yOffset = 0; 1786 1787 handle.addEventListener('mousedown', dragStart); 1788 document.addEventListener('mousemove', drag); 1789 document.addEventListener('mouseup', dragEnd); 1790 1791 function dragStart(e) { 1792 initialX = e.clientX - xOffset; 1793 initialY = e.clientY - yOffset; 1794 isDragging = true; 1795 } 1796 1797 function drag(e) { 1798 if (isDragging) { 1799 e.preventDefault(); 1800 currentX = e.clientX - initialX; 1801 currentY = e.clientY - initialY; 1802 xOffset = currentX; 1803 yOffset = currentY; 1804 setTranslate(currentX, currentY, dialog); 1805 } 1806 } 1807 1808 function dragEnd(e) { 1809 initialX = currentX; 1810 initialY = currentY; 1811 isDragging = false; 1812 } 1813 1814 function setTranslate(xPos, yPos, el) { 1815 el.style.transform = `translate(${xPos}px, ${yPos}px)`; 1816 } 1817}; 1818 1819// Initialize dialog draggability when opened (avoid duplicate declaration) 1820if (!window.calendarDraggabilityPatched) { 1821 window.calendarDraggabilityPatched = true; 1822 1823 const originalOpenAddEvent = openAddEvent; 1824 openAddEvent = function(calId, namespace, date) { 1825 originalOpenAddEvent(calId, namespace, date); 1826 setTimeout(() => makeDialogDraggable(calId), 100); 1827 }; 1828 1829 const originalEditEvent = editEvent; 1830 editEvent = function(calId, eventId, date, namespace) { 1831 originalEditEvent(calId, eventId, date, namespace); 1832 setTimeout(() => makeDialogDraggable(calId), 100); 1833 }; 1834} 1835 1836// Toggle expand/collapse for past events 1837window.togglePastEventExpand = function(element) { 1838 // Stop propagation to prevent any parent click handlers 1839 event.stopPropagation(); 1840 1841 const meta = element.querySelector(".event-meta-compact"); 1842 const desc = element.querySelector(".event-desc-compact"); 1843 1844 // Toggle visibility 1845 if (meta.style.display === "none") { 1846 // Expand 1847 meta.style.display = "block"; 1848 if (desc) desc.style.display = "block"; 1849 element.classList.add("event-past-expanded"); 1850 } else { 1851 // Collapse 1852 meta.style.display = "none"; 1853 if (desc) desc.style.display = "none"; 1854 element.classList.remove("event-past-expanded"); 1855 } 1856}; 1857 1858// Filter calendar by namespace when clicking namespace badge (guarded) 1859if (!window._calendarClickDelegationInit) { 1860 window._calendarClickDelegationInit = true; 1861 document.addEventListener('click', function(e) { 1862 if (e.target.classList.contains('event-namespace-badge')) { 1863 const namespace = e.target.textContent; 1864 const eventItem = e.target.closest('.event-compact-item'); 1865 const eventList = e.target.closest('.event-list-compact'); 1866 const calendar = e.target.closest('.calendar-compact-container'); 1867 1868 if (!eventList || !calendar) return; 1869 1870 const calId = calendar.id; 1871 1872 // Check if already filtered 1873 const isFiltered = eventList.classList.contains('namespace-filtered'); 1874 1875 if (isFiltered && eventList.dataset.filterNamespace === namespace) { 1876 // Unfilter - show all 1877 eventList.classList.remove('namespace-filtered'); 1878 delete eventList.dataset.filterNamespace; 1879 delete calendar.dataset.filteredNamespace; 1880 eventList.querySelectorAll('.event-compact-item').forEach(item => { 1881 item.style.display = ''; 1882 }); 1883 1884 // Update header to show "all namespaces" 1885 updateFilteredNamespaceDisplay(calId, null); 1886 } else { 1887 // Filter by this namespace 1888 eventList.classList.add('namespace-filtered'); 1889 eventList.dataset.filterNamespace = namespace; 1890 calendar.dataset.filteredNamespace = namespace; 1891 eventList.querySelectorAll('.event-compact-item').forEach(item => { 1892 const itemBadge = item.querySelector('.event-namespace-badge'); 1893 if (itemBadge && itemBadge.textContent === namespace) { 1894 item.style.display = ''; 1895 } else { 1896 item.style.display = 'none'; 1897 } 1898 }); 1899 1900 // Update header to show filtered namespace 1901 updateFilteredNamespaceDisplay(calId, namespace); 1902 } 1903 } 1904 }); 1905} // end click delegation guard 1906 1907// Update the displayed filtered namespace in event list header 1908window.updateFilteredNamespaceDisplay = function(calId, namespace) { 1909 const calendar = document.getElementById(calId); 1910 if (!calendar) return; 1911 1912 const headerContent = calendar.querySelector('.event-list-header-content'); 1913 if (!headerContent) return; 1914 1915 // Remove existing filter badge 1916 let filterBadge = headerContent.querySelector('.namespace-filter-badge'); 1917 if (filterBadge) { 1918 filterBadge.remove(); 1919 } 1920 1921 // Add new filter badge if filtering 1922 if (namespace) { 1923 filterBadge = document.createElement('span'); 1924 filterBadge.className = 'namespace-badge namespace-filter-badge'; 1925 filterBadge.innerHTML = escapeHtml(namespace) + ' <button class="filter-clear-inline" onclick="clearNamespaceFilter(\'' + calId + '\'); event.stopPropagation();">✕</button>'; 1926 headerContent.appendChild(filterBadge); 1927 } 1928}; 1929 1930// Clear namespace filter 1931window.clearNamespaceFilter = function(calId) { 1932 1933 const container = document.getElementById(calId); 1934 if (!container) { 1935 console.error('Calendar container not found:', calId); 1936 return; 1937 } 1938 1939 // Immediately hide/remove the filter badge 1940 const filterBadge = container.querySelector('.calendar-namespace-filter'); 1941 if (filterBadge) { 1942 filterBadge.style.display = 'none'; 1943 filterBadge.remove(); 1944 } 1945 1946 // Get current year and month 1947 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 1948 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 1949 1950 // Get original namespace (what the calendar was initialized with) 1951 const originalNamespace = container.dataset.originalNamespace || ''; 1952 1953 // Also check for sidebar widget 1954 const sidebarContainer = document.getElementById('sidebar-widget-' + calId); 1955 if (sidebarContainer) { 1956 // For sidebar widget, just reload the page without namespace filter 1957 // Remove the namespace from the URL and reload 1958 const url = new URL(window.location.href); 1959 url.searchParams.delete('namespace'); 1960 window.location.href = url.toString(); 1961 return; 1962 } 1963 1964 // For regular calendar, reload calendar with original namespace 1965 navCalendar(calId, year, month, originalNamespace); 1966}; 1967 1968window.clearNamespaceFilterPanel = function(calId) { 1969 1970 const container = document.getElementById(calId); 1971 if (!container) { 1972 console.error('Event panel container not found:', calId); 1973 return; 1974 } 1975 1976 // Get current year and month from URL params or container 1977 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 1978 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 1979 1980 // Get original namespace (what the panel was initialized with) 1981 const originalNamespace = container.dataset.originalNamespace || ''; 1982 1983 1984 // Reload event panel with original namespace 1985 navEventPanel(calId, year, month, originalNamespace); 1986}; 1987 1988// Color picker functions 1989window.updateCustomColorPicker = function(calId) { 1990 const select = document.getElementById('event-color-' + calId); 1991 const picker = document.getElementById('event-color-custom-' + calId); 1992 1993 if (select.value === 'custom') { 1994 // Show color picker 1995 picker.style.display = 'inline-block'; 1996 picker.click(); // Open color picker 1997 } else { 1998 // Hide color picker and sync value 1999 picker.style.display = 'none'; 2000 picker.value = select.value; 2001 } 2002}; 2003 2004function updateColorFromPicker(calId) { 2005 const select = document.getElementById('event-color-' + calId); 2006 const picker = document.getElementById('event-color-custom-' + calId); 2007 2008 // Set select to custom and update its underlying value 2009 select.value = 'custom'; 2010 // Store the actual color value in a data attribute 2011 select.dataset.customColor = picker.value; 2012} 2013 2014// Toggle past events visibility 2015window.togglePastEvents = function(calId) { 2016 const content = document.getElementById('past-events-' + calId); 2017 const arrow = document.getElementById('past-arrow-' + calId); 2018 2019 if (!content || !arrow) { 2020 console.error('Past events elements not found for:', calId); 2021 return; 2022 } 2023 2024 // Check computed style instead of inline style 2025 const isHidden = window.getComputedStyle(content).display === 'none'; 2026 2027 if (isHidden) { 2028 content.style.display = 'block'; 2029 arrow.textContent = '▼'; 2030 } else { 2031 content.style.display = 'none'; 2032 arrow.textContent = '▶'; 2033 } 2034}; 2035 2036// Fuzzy match scoring function 2037window.fuzzyMatch = function(pattern, str) { 2038 pattern = pattern.toLowerCase(); 2039 str = str.toLowerCase(); 2040 2041 let patternIdx = 0; 2042 let score = 0; 2043 let consecutiveMatches = 0; 2044 2045 for (let i = 0; i < str.length; i++) { 2046 if (patternIdx < pattern.length && str[i] === pattern[patternIdx]) { 2047 score += 1 + consecutiveMatches; 2048 consecutiveMatches++; 2049 patternIdx++; 2050 } else { 2051 consecutiveMatches = 0; 2052 } 2053 } 2054 2055 // Return null if not all characters matched 2056 if (patternIdx !== pattern.length) { 2057 return null; 2058 } 2059 2060 // Bonus for exact match 2061 if (str === pattern) { 2062 score += 100; 2063 } 2064 2065 // Bonus for starts with 2066 if (str.startsWith(pattern)) { 2067 score += 50; 2068 } 2069 2070 return score; 2071}; 2072 2073// Initialize namespace search for a calendar 2074window.initNamespaceSearch = function(calId) { 2075 const searchInput = document.getElementById('event-namespace-search-' + calId); 2076 const hiddenInput = document.getElementById('event-namespace-' + calId); 2077 const dropdown = document.getElementById('event-namespace-dropdown-' + calId); 2078 const dataElement = document.getElementById('namespaces-data-' + calId); 2079 2080 if (!searchInput || !hiddenInput || !dropdown || !dataElement) { 2081 return; // Elements not found 2082 } 2083 2084 let namespaces = []; 2085 try { 2086 namespaces = JSON.parse(dataElement.textContent); 2087 } catch (e) { 2088 console.error('Failed to parse namespaces data:', e); 2089 return; 2090 } 2091 2092 let selectedIndex = -1; 2093 2094 // Filter and show dropdown 2095 function filterNamespaces(query) { 2096 if (!query || query.trim() === '') { 2097 // Show all namespaces when empty 2098 hiddenInput.value = ''; 2099 const results = namespaces.slice(0, 20); // Limit to 20 2100 showDropdown(results); 2101 return; 2102 } 2103 2104 // Fuzzy match and score 2105 const matches = []; 2106 for (let i = 0; i < namespaces.length; i++) { 2107 const score = fuzzyMatch(query, namespaces[i]); 2108 if (score !== null) { 2109 matches.push({ namespace: namespaces[i], score: score }); 2110 } 2111 } 2112 2113 // Sort by score (descending) 2114 matches.sort((a, b) => b.score - a.score); 2115 2116 // Take top 20 results 2117 const results = matches.slice(0, 20).map(m => m.namespace); 2118 showDropdown(results); 2119 } 2120 2121 function showDropdown(results) { 2122 dropdown.innerHTML = ''; 2123 selectedIndex = -1; 2124 2125 if (results.length === 0) { 2126 dropdown.style.display = 'none'; 2127 return; 2128 } 2129 2130 // Add (default) option 2131 const defaultOption = document.createElement('div'); 2132 defaultOption.className = 'namespace-option'; 2133 defaultOption.textContent = '(default)'; 2134 defaultOption.dataset.value = ''; 2135 dropdown.appendChild(defaultOption); 2136 2137 results.forEach(ns => { 2138 const option = document.createElement('div'); 2139 option.className = 'namespace-option'; 2140 option.textContent = ns; 2141 option.dataset.value = ns; 2142 dropdown.appendChild(option); 2143 }); 2144 2145 dropdown.style.display = 'block'; 2146 } 2147 2148 function hideDropdown() { 2149 dropdown.style.display = 'none'; 2150 selectedIndex = -1; 2151 } 2152 2153 function selectOption(namespace) { 2154 hiddenInput.value = namespace; 2155 searchInput.value = namespace || '(default)'; 2156 hideDropdown(); 2157 } 2158 2159 // Event listeners 2160 searchInput.addEventListener('input', function(e) { 2161 filterNamespaces(e.target.value); 2162 }); 2163 2164 searchInput.addEventListener('focus', function(e) { 2165 filterNamespaces(e.target.value); 2166 }); 2167 2168 searchInput.addEventListener('blur', function(e) { 2169 // Delay to allow click on dropdown 2170 setTimeout(hideDropdown, 200); 2171 }); 2172 2173 searchInput.addEventListener('keydown', function(e) { 2174 const options = dropdown.querySelectorAll('.namespace-option'); 2175 2176 if (e.key === 'ArrowDown') { 2177 e.preventDefault(); 2178 selectedIndex = Math.min(selectedIndex + 1, options.length - 1); 2179 updateSelection(options); 2180 } else if (e.key === 'ArrowUp') { 2181 e.preventDefault(); 2182 selectedIndex = Math.max(selectedIndex - 1, -1); 2183 updateSelection(options); 2184 } else if (e.key === 'Enter') { 2185 e.preventDefault(); 2186 if (selectedIndex >= 0 && options[selectedIndex]) { 2187 selectOption(options[selectedIndex].dataset.value); 2188 } 2189 } else if (e.key === 'Escape') { 2190 hideDropdown(); 2191 } 2192 }); 2193 2194 function updateSelection(options) { 2195 options.forEach((opt, idx) => { 2196 if (idx === selectedIndex) { 2197 opt.classList.add('selected'); 2198 opt.scrollIntoView({ block: 'nearest' }); 2199 } else { 2200 opt.classList.remove('selected'); 2201 } 2202 }); 2203 } 2204 2205 // Click on dropdown option 2206 dropdown.addEventListener('mousedown', function(e) { 2207 if (e.target.classList.contains('namespace-option')) { 2208 selectOption(e.target.dataset.value); 2209 } 2210 }); 2211}; 2212 2213// Update end time options based on start time selection 2214window.updateEndTimeOptions = function(calId) { 2215 const startTimeSelect = document.getElementById('event-time-' + calId); 2216 const endTimeSelect = document.getElementById('event-end-time-' + calId); 2217 2218 if (!startTimeSelect || !endTimeSelect) return; 2219 2220 const startTime = startTimeSelect.value; 2221 2222 // If start time is empty (all day), disable end time 2223 if (!startTime) { 2224 endTimeSelect.disabled = true; 2225 endTimeSelect.value = ''; 2226 return; 2227 } 2228 2229 // Enable end time select 2230 endTimeSelect.disabled = false; 2231 2232 // Convert start time to minutes 2233 const startMinutes = timeToMinutes(startTime); 2234 2235 // Get current end time value (to preserve if valid) 2236 const currentEndTime = endTimeSelect.value; 2237 const currentEndMinutes = currentEndTime ? timeToMinutes(currentEndTime) : 0; 2238 2239 // Filter options - show only times after start time 2240 const options = endTimeSelect.options; 2241 let firstValidOption = null; 2242 let currentStillValid = false; 2243 2244 for (let i = 0; i < options.length; i++) { 2245 const option = options[i]; 2246 const optionValue = option.value; 2247 2248 if (optionValue === '') { 2249 // Keep "Same as start" option visible 2250 option.style.display = ''; 2251 continue; 2252 } 2253 2254 const optionMinutes = timeToMinutes(optionValue); 2255 2256 if (optionMinutes > startMinutes) { 2257 // Show options after start time 2258 option.style.display = ''; 2259 if (!firstValidOption) { 2260 firstValidOption = optionValue; 2261 } 2262 if (optionValue === currentEndTime) { 2263 currentStillValid = true; 2264 } 2265 } else { 2266 // Hide options before or equal to start time 2267 option.style.display = 'none'; 2268 } 2269 } 2270 2271 // If current end time is now invalid, set a new one 2272 if (!currentStillValid || currentEndMinutes <= startMinutes) { 2273 // Try to set to 1 hour after start 2274 const [startHour, startMinute] = startTime.split(':').map(Number); 2275 let endHour = startHour + 1; 2276 let endMinute = startMinute; 2277 2278 if (endHour >= 24) { 2279 endHour = 23; 2280 endMinute = 45; 2281 } 2282 2283 const suggestedEndTime = String(endHour).padStart(2, '0') + ':' + String(endMinute).padStart(2, '0'); 2284 2285 // Check if suggested time is in the list 2286 const suggestedExists = Array.from(options).some(opt => opt.value === suggestedEndTime); 2287 2288 if (suggestedExists) { 2289 endTimeSelect.value = suggestedEndTime; 2290 } else if (firstValidOption) { 2291 // Use first valid option 2292 endTimeSelect.value = firstValidOption; 2293 } else { 2294 // No valid options (shouldn't happen, but just in case) 2295 endTimeSelect.value = ''; 2296 } 2297 } 2298}; 2299 2300// Check for time conflicts between events on the same date 2301window.checkTimeConflicts = function(events, currentEventId) { 2302 const conflicts = []; 2303 2304 // Group events by date 2305 const eventsByDate = {}; 2306 for (const [date, dateEvents] of Object.entries(events)) { 2307 if (!Array.isArray(dateEvents)) continue; 2308 2309 dateEvents.forEach(evt => { 2310 if (!evt.time || evt.id === currentEventId) return; // Skip all-day events and current event 2311 2312 if (!eventsByDate[date]) eventsByDate[date] = []; 2313 eventsByDate[date].push(evt); 2314 }); 2315 } 2316 2317 // Check for overlaps on each date 2318 for (const [date, dateEvents] of Object.entries(eventsByDate)) { 2319 for (let i = 0; i < dateEvents.length; i++) { 2320 for (let j = i + 1; j < dateEvents.length; j++) { 2321 const evt1 = dateEvents[i]; 2322 const evt2 = dateEvents[j]; 2323 2324 if (eventsOverlap(evt1, evt2)) { 2325 // Mark both events as conflicting 2326 if (!evt1.hasConflict) evt1.hasConflict = true; 2327 if (!evt2.hasConflict) evt2.hasConflict = true; 2328 2329 // Store conflict info 2330 if (!evt1.conflictsWith) evt1.conflictsWith = []; 2331 if (!evt2.conflictsWith) evt2.conflictsWith = []; 2332 2333 evt1.conflictsWith.push({id: evt2.id, title: evt2.title, time: evt2.time, endTime: evt2.endTime}); 2334 evt2.conflictsWith.push({id: evt1.id, title: evt1.title, time: evt1.time, endTime: evt1.endTime}); 2335 } 2336 } 2337 } 2338 } 2339 2340 return events; 2341}; 2342 2343// Check if two events overlap in time 2344function eventsOverlap(evt1, evt2) { 2345 if (!evt1.time || !evt2.time) return false; // All-day events don't conflict 2346 2347 const start1 = evt1.time; 2348 const end1 = evt1.endTime || evt1.time; // If no end time, treat as same as start 2349 2350 const start2 = evt2.time; 2351 const end2 = evt2.endTime || evt2.time; 2352 2353 // Convert to minutes for easier comparison 2354 const start1Mins = timeToMinutes(start1); 2355 const end1Mins = timeToMinutes(end1); 2356 const start2Mins = timeToMinutes(start2); 2357 const end2Mins = timeToMinutes(end2); 2358 2359 // Check for overlap 2360 // Events overlap if: start1 < end2 AND start2 < end1 2361 return start1Mins < end2Mins && start2Mins < end1Mins; 2362} 2363 2364// Convert HH:MM time to minutes since midnight 2365function timeToMinutes(timeStr) { 2366 const [hours, minutes] = timeStr.split(':').map(Number); 2367 return hours * 60 + minutes; 2368} 2369 2370// Format time range for display 2371window.formatTimeRange = function(startTime, endTime) { 2372 if (!startTime) return ''; 2373 2374 const formatTime = (timeStr) => { 2375 const [hour24, minute] = timeStr.split(':').map(Number); 2376 const hour12 = hour24 === 0 ? 12 : (hour24 > 12 ? hour24 - 12 : hour24); 2377 const ampm = hour24 < 12 ? 'AM' : 'PM'; 2378 return hour12 + ':' + String(minute).padStart(2, '0') + ' ' + ampm; 2379 }; 2380 2381 if (!endTime || endTime === startTime) { 2382 return formatTime(startTime); 2383 } 2384 2385 return formatTime(startTime) + ' - ' + formatTime(endTime); 2386}; 2387 2388// Track last known mouse position for tooltip positioning fallback 2389var _lastMouseX = 0, _lastMouseY = 0; 2390document.addEventListener('mousemove', function(e) { 2391 _lastMouseX = e.clientX; 2392 _lastMouseY = e.clientY; 2393}); 2394 2395// Show custom conflict tooltip 2396window.showConflictTooltip = function(badgeElement) { 2397 // Remove any existing tooltip 2398 hideConflictTooltip(); 2399 2400 // Get conflict data (base64-encoded JSON to avoid attribute quote issues) 2401 const conflictsRaw = badgeElement.getAttribute('data-conflicts'); 2402 if (!conflictsRaw) return; 2403 2404 let conflicts; 2405 try { 2406 conflicts = JSON.parse(decodeURIComponent(escape(atob(conflictsRaw)))); 2407 } catch (e) { 2408 // Fallback: try parsing as plain JSON (for PHP-rendered badges) 2409 try { 2410 conflicts = JSON.parse(conflictsRaw); 2411 } catch (e2) { 2412 console.error('Failed to parse conflicts:', e2); 2413 return; 2414 } 2415 } 2416 2417 // Get theme from the calendar container via CSS variables 2418 // Try closest ancestor first, then fall back to any calendar on the page 2419 let containerEl = badgeElement.closest('[id^="cal_"], [id^="panel_"], [id^="sidebar-widget-"], .calendar-compact-container, .event-panel-standalone'); 2420 if (!containerEl) { 2421 // Badge might be inside a day popup (appended to body) - find any calendar container 2422 containerEl = document.querySelector('.calendar-compact-container, .event-panel-standalone, [id^="sidebar-widget-"]'); 2423 } 2424 const cs = containerEl ? getComputedStyle(containerEl) : null; 2425 2426 const bg = cs ? cs.getPropertyValue('--background-site').trim() || '#242424' : '#242424'; 2427 const border = cs ? cs.getPropertyValue('--border-main').trim() || '#00cc07' : '#00cc07'; 2428 const textPrimary = cs ? cs.getPropertyValue('--text-primary').trim() || '#00cc07' : '#00cc07'; 2429 const textDim = cs ? cs.getPropertyValue('--text-dim').trim() || '#00aa00' : '#00aa00'; 2430 const shadow = cs ? cs.getPropertyValue('--shadow-color').trim() || 'rgba(0, 204, 7, 0.3)' : 'rgba(0, 204, 7, 0.3)'; 2431 2432 // Create tooltip 2433 const tooltip = document.createElement('div'); 2434 tooltip.id = 'conflict-tooltip'; 2435 tooltip.className = 'conflict-tooltip'; 2436 2437 // Apply theme styles 2438 tooltip.style.background = bg; 2439 tooltip.style.borderColor = border; 2440 tooltip.style.color = textPrimary; 2441 tooltip.style.boxShadow = '0 4px 12px ' + shadow; 2442 2443 // Build content with themed colors 2444 let html = '<div class="conflict-tooltip-header" style="color: ' + textPrimary + '; border-bottom: 1px solid ' + border + ';">⚠️ Time Conflicts</div>'; 2445 html += '<div class="conflict-tooltip-body">'; 2446 conflicts.forEach(conflict => { 2447 html += '<div class="conflict-item" style="color: ' + textDim + ';">• ' + escapeHtml(conflict) + '</div>'; 2448 }); 2449 html += '</div>'; 2450 2451 tooltip.innerHTML = html; 2452 document.body.appendChild(tooltip); 2453 2454 // Position tooltip 2455 const rect = badgeElement.getBoundingClientRect(); 2456 const tooltipRect = tooltip.getBoundingClientRect(); 2457 2458 // Position above the badge, centered 2459 let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); 2460 let top = rect.top - tooltipRect.height - 8; 2461 2462 // Keep tooltip within viewport 2463 if (left < 10) left = 10; 2464 if (left + tooltipRect.width > window.innerWidth - 10) { 2465 left = window.innerWidth - tooltipRect.width - 10; 2466 } 2467 if (top < 10) { 2468 // If not enough room above, show below 2469 top = rect.bottom + 8; 2470 } 2471 2472 tooltip.style.left = left + 'px'; 2473 tooltip.style.top = top + 'px'; 2474 tooltip.style.opacity = '1'; 2475}; 2476 2477// Hide conflict tooltip 2478window.hideConflictTooltip = function() { 2479 const tooltip = document.getElementById('conflict-tooltip'); 2480 if (tooltip) { 2481 tooltip.remove(); 2482 } 2483}; 2484 2485// Filter events by search term 2486window.filterEvents = function(calId, searchTerm) { 2487 const eventList = document.getElementById('eventlist-' + calId); 2488 const searchClear = document.getElementById('search-clear-' + calId); 2489 2490 if (!eventList) return; 2491 2492 // Show/hide clear button 2493 if (searchClear) { 2494 searchClear.style.display = searchTerm ? 'block' : 'none'; 2495 } 2496 2497 searchTerm = searchTerm.toLowerCase().trim(); 2498 2499 // Get all event items 2500 const eventItems = eventList.querySelectorAll('.event-compact-item'); 2501 let visibleCount = 0; 2502 let hiddenPastCount = 0; 2503 2504 eventItems.forEach(item => { 2505 const title = item.querySelector('.event-title-compact'); 2506 const description = item.querySelector('.event-desc-compact'); 2507 const dateTime = item.querySelector('.event-date-time'); 2508 2509 // Build searchable text 2510 let searchableText = ''; 2511 if (title) searchableText += title.textContent.toLowerCase() + ' '; 2512 if (description) searchableText += description.textContent.toLowerCase() + ' '; 2513 if (dateTime) searchableText += dateTime.textContent.toLowerCase() + ' '; 2514 2515 // Check if matches search 2516 const matches = !searchTerm || searchableText.includes(searchTerm); 2517 2518 if (matches) { 2519 item.style.display = ''; 2520 visibleCount++; 2521 } else { 2522 item.style.display = 'none'; 2523 // Check if this is a past event 2524 if (item.classList.contains('event-past') || item.classList.contains('event-completed')) { 2525 hiddenPastCount++; 2526 } 2527 } 2528 }); 2529 2530 // Update past events toggle if it exists 2531 const pastToggle = eventList.querySelector('.past-events-toggle'); 2532 const pastLabel = eventList.querySelector('.past-events-label'); 2533 const pastContent = document.getElementById('past-events-' + calId); 2534 2535 if (pastToggle && pastLabel && pastContent) { 2536 const visiblePastEvents = pastContent.querySelectorAll('.event-compact-item:not([style*="display: none"])'); 2537 const totalPastVisible = visiblePastEvents.length; 2538 2539 if (totalPastVisible > 0) { 2540 pastLabel.textContent = `Past Events (${totalPastVisible})`; 2541 pastToggle.style.display = ''; 2542 } else { 2543 pastToggle.style.display = 'none'; 2544 } 2545 } 2546 2547 // Show "no results" message if nothing visible 2548 let noResultsMsg = eventList.querySelector('.no-search-results'); 2549 if (visibleCount === 0 && searchTerm) { 2550 if (!noResultsMsg) { 2551 noResultsMsg = document.createElement('p'); 2552 noResultsMsg.className = 'no-search-results no-events-msg'; 2553 noResultsMsg.textContent = 'No events match your search'; 2554 eventList.appendChild(noResultsMsg); 2555 } 2556 noResultsMsg.style.display = 'block'; 2557 } else if (noResultsMsg) { 2558 noResultsMsg.style.display = 'none'; 2559 } 2560}; 2561 2562// Clear event search 2563window.clearEventSearch = function(calId) { 2564 const searchInput = document.getElementById('event-search-' + calId); 2565 if (searchInput) { 2566 searchInput.value = ''; 2567 filterEvents(calId, ''); 2568 searchInput.focus(); 2569 } 2570}; 2571 2572// ============================================ 2573// PINK THEME - GLOWING PARTICLE EFFECTS 2574// ============================================ 2575 2576// Create glowing pink particle effects for pink theme 2577(function() { 2578 let pinkThemeActive = false; 2579 let trailTimer = null; 2580 let pixelTimer = null; 2581 2582 // Check if pink theme is active 2583 function checkPinkTheme() { 2584 const pinkCalendars = document.querySelectorAll('.calendar-theme-pink'); 2585 pinkThemeActive = pinkCalendars.length > 0; 2586 return pinkThemeActive; 2587 } 2588 2589 // Create trail particle 2590 function createTrailParticle(clientX, clientY) { 2591 if (!pinkThemeActive) return; 2592 2593 const trail = document.createElement('div'); 2594 trail.className = 'pink-cursor-trail'; 2595 trail.style.left = clientX + 'px'; 2596 trail.style.top = clientY + 'px'; 2597 trail.style.animation = 'cursor-trail-fade 0.5s ease-out forwards'; 2598 2599 document.body.appendChild(trail); 2600 2601 setTimeout(function() { 2602 trail.remove(); 2603 }, 500); 2604 } 2605 2606 // Create pixel sparkles 2607 function createPixelSparkles(clientX, clientY) { 2608 if (!pinkThemeActive || pixelTimer) return; 2609 2610 const pixelCount = 3 + Math.floor(Math.random() * 4); // 3-6 pixels 2611 2612 for (let i = 0; i < pixelCount; i++) { 2613 const pixel = document.createElement('div'); 2614 pixel.className = 'pink-pixel-sparkle'; 2615 2616 // Random offset from cursor 2617 const offsetX = (Math.random() - 0.5) * 30; 2618 const offsetY = (Math.random() - 0.5) * 30; 2619 2620 pixel.style.left = (clientX + offsetX) + 'px'; 2621 pixel.style.top = (clientY + offsetY) + 'px'; 2622 2623 // Random color - bright neon pinks and whites 2624 const colors = ['#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1']; 2625 const color = colors[Math.floor(Math.random() * colors.length)]; 2626 pixel.style.background = color; 2627 pixel.style.boxShadow = '0 0 2px ' + color + ', 0 0 4px ' + color + ', 0 0 6px #fff'; 2628 2629 // Random animation 2630 if (Math.random() > 0.5) { 2631 pixel.style.animation = 'pixel-twinkle 0.6s ease-out forwards'; 2632 } else { 2633 pixel.style.animation = 'pixel-float-away 0.8s ease-out forwards'; 2634 } 2635 2636 document.body.appendChild(pixel); 2637 2638 setTimeout(function() { 2639 pixel.remove(); 2640 }, 800); 2641 } 2642 2643 pixelTimer = setTimeout(function() { 2644 pixelTimer = null; 2645 }, 40); 2646 } 2647 2648 // Create explosion 2649 function createExplosion(clientX, clientY) { 2650 if (!pinkThemeActive) return; 2651 2652 const particleCount = 25; 2653 const colors = ['#ff1493', '#ff69b4', '#ff85c1', '#ffc0cb', '#fff']; 2654 2655 // Add hearts to explosion (8-12 hearts) 2656 const heartCount = 8 + Math.floor(Math.random() * 5); 2657 for (let i = 0; i < heartCount; i++) { 2658 const heart = document.createElement('div'); 2659 heart.textContent = ''; 2660 heart.style.position = 'fixed'; 2661 heart.style.left = clientX + 'px'; 2662 heart.style.top = clientY + 'px'; 2663 heart.style.pointerEvents = 'none'; 2664 heart.style.zIndex = '9999999'; 2665 heart.style.fontSize = (12 + Math.random() * 16) + 'px'; 2666 2667 // Random direction 2668 const angle = Math.random() * Math.PI * 2; 2669 const velocity = 60 + Math.random() * 80; 2670 const tx = Math.cos(angle) * velocity; 2671 const ty = Math.sin(angle) * velocity; 2672 2673 heart.style.setProperty('--tx', tx + 'px'); 2674 heart.style.setProperty('--ty', ty + 'px'); 2675 2676 const duration = 0.8 + Math.random() * 0.4; 2677 heart.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 2678 2679 document.body.appendChild(heart); 2680 2681 setTimeout(function() { 2682 heart.remove(); 2683 }, duration * 1000); 2684 } 2685 2686 // Main explosion particles 2687 for (let i = 0; i < particleCount; i++) { 2688 const particle = document.createElement('div'); 2689 particle.className = 'pink-particle'; 2690 2691 const color = colors[Math.floor(Math.random() * colors.length)]; 2692 particle.style.background = 'radial-gradient(circle, ' + color + ', transparent)'; 2693 particle.style.boxShadow = '0 0 10px ' + color + ', 0 0 20px ' + color; 2694 2695 particle.style.left = clientX + 'px'; 2696 particle.style.top = clientY + 'px'; 2697 2698 const angle = (Math.PI * 2 * i) / particleCount; 2699 const velocity = 50 + Math.random() * 100; 2700 const tx = Math.cos(angle) * velocity; 2701 const ty = Math.sin(angle) * velocity; 2702 2703 particle.style.setProperty('--tx', tx + 'px'); 2704 particle.style.setProperty('--ty', ty + 'px'); 2705 2706 const size = 4 + Math.random() * 6; 2707 particle.style.width = size + 'px'; 2708 particle.style.height = size + 'px'; 2709 2710 const duration = 0.6 + Math.random() * 0.4; 2711 particle.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 2712 2713 document.body.appendChild(particle); 2714 2715 setTimeout(function() { 2716 particle.remove(); 2717 }, duration * 1000); 2718 } 2719 2720 // Pixel sparkles 2721 const pixelSparkleCount = 40; 2722 2723 for (let i = 0; i < pixelSparkleCount; i++) { 2724 const pixel = document.createElement('div'); 2725 pixel.className = 'pink-pixel-sparkle'; 2726 2727 const pixelColors = ['#fff', '#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1']; 2728 const pixelColor = pixelColors[Math.floor(Math.random() * pixelColors.length)]; 2729 pixel.style.background = pixelColor; 2730 pixel.style.boxShadow = '0 0 3px ' + pixelColor + ', 0 0 6px ' + pixelColor + ', 0 0 9px #fff'; 2731 2732 const angle = Math.random() * Math.PI * 2; 2733 const distance = 30 + Math.random() * 80; 2734 const offsetX = Math.cos(angle) * distance; 2735 const offsetY = Math.sin(angle) * distance; 2736 2737 pixel.style.left = clientX + 'px'; 2738 pixel.style.top = clientY + 'px'; 2739 pixel.style.setProperty('--tx', offsetX + 'px'); 2740 pixel.style.setProperty('--ty', offsetY + 'px'); 2741 2742 const pixelSize = 1 + Math.random() * 2; 2743 pixel.style.width = pixelSize + 'px'; 2744 pixel.style.height = pixelSize + 'px'; 2745 2746 const duration = 0.4 + Math.random() * 0.4; 2747 if (Math.random() > 0.5) { 2748 pixel.style.animation = 'pixel-twinkle ' + duration + 's ease-out forwards'; 2749 } else { 2750 pixel.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 2751 } 2752 2753 document.body.appendChild(pixel); 2754 2755 setTimeout(function() { 2756 pixel.remove(); 2757 }, duration * 1000); 2758 } 2759 2760 // Flash 2761 const flash = document.createElement('div'); 2762 flash.style.position = 'fixed'; 2763 flash.style.left = clientX + 'px'; 2764 flash.style.top = clientY + 'px'; 2765 flash.style.width = '40px'; 2766 flash.style.height = '40px'; 2767 flash.style.borderRadius = '50%'; 2768 flash.style.background = 'radial-gradient(circle, rgba(255, 255, 255, 0.9), rgba(255, 20, 147, 0.6), transparent)'; 2769 flash.style.boxShadow = '0 0 40px #fff, 0 0 60px #ff1493, 0 0 80px #ff69b4'; 2770 flash.style.pointerEvents = 'none'; 2771 flash.style.zIndex = '9999999'; // Above everything including dialogs 2772 flash.style.transform = 'translate(-50%, -50%)'; 2773 flash.style.animation = 'cursor-trail-fade 0.3s ease-out forwards'; 2774 2775 document.body.appendChild(flash); 2776 2777 setTimeout(function() { 2778 flash.remove(); 2779 }, 300); 2780 } 2781 2782 function initPinkParticles() { 2783 if (!checkPinkTheme()) return; 2784 2785 // Use capture phase to catch events before stopPropagation 2786 document.addEventListener('mousemove', function(e) { 2787 if (!pinkThemeActive) return; 2788 2789 createTrailParticle(e.clientX, e.clientY); 2790 createPixelSparkles(e.clientX, e.clientY); 2791 }, true); // Capture phase! 2792 2793 // Throttle main trail 2794 document.addEventListener('mousemove', function(e) { 2795 if (!pinkThemeActive || trailTimer) return; 2796 2797 trailTimer = setTimeout(function() { 2798 trailTimer = null; 2799 }, 30); 2800 }, true); // Capture phase! 2801 2802 // Click explosion - use capture phase 2803 document.addEventListener('click', function(e) { 2804 if (!pinkThemeActive) return; 2805 2806 createExplosion(e.clientX, e.clientY); 2807 }, true); // Capture phase! 2808 } 2809 2810 // Initialize on load 2811 if (document.readyState === 'loading') { 2812 document.addEventListener('DOMContentLoaded', initPinkParticles); 2813 } else { 2814 initPinkParticles(); 2815 } 2816 2817 // Re-check theme if calendar is dynamically added 2818 if (typeof MutationObserver !== 'undefined') { 2819 const observer = new MutationObserver(function(mutations) { 2820 mutations.forEach(function(mutation) { 2821 if (mutation.addedNodes.length > 0) { 2822 mutation.addedNodes.forEach(function(node) { 2823 if (node.nodeType === 1 && node.classList && node.classList.contains('calendar-theme-pink')) { 2824 checkPinkTheme(); 2825 initPinkParticles(); 2826 } 2827 }); 2828 } 2829 }); 2830 }); 2831 2832 observer.observe(document.body, { 2833 childList: true, 2834 subtree: true 2835 }); 2836 } 2837})(); 2838 2839// End of calendar plugin JavaScript 2840