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 + ' !important;" 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" style="background:var(--pastdue-color, #e74c3c) !important; color:white !important; -webkit-text-fill-color:white !important;">PAST DUE</span>'; 925 } else if (isToday) { 926 html += ' <span class="event-today-badge" style="background:var(--border-main, #9b59b6) !important; color:var(--background-site, white) !important; -webkit-text-fill-color:var(--background-site, white) !important;">TODAY</span>'; 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) + '\')" style="background:var(--text-bright, #008800) !important; color:var(--background-site, white) !important; -webkit-text-fill-color:var(--background-site, white) !important;" title="Click to filter by this namespace">' + escapeHtml(eventNamespace) + '</span>'; 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) + '\')" style="background:var(--text-bright, #008800) !important; color:var(--background-site, white) !important; -webkit-text-fill-color:var(--background-site, white) !important;" title="Click to filter by this namespace">' + escapeHtml(eventNamespace) + '</span>'; 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 } else { 1317 // No namespace on event, set to default 1318 if (namespaceHidden) { 1319 namespaceHidden.value = ''; 1320 } 1321 if (namespaceSearch) { 1322 namespaceSearch.value = '(default)'; 1323 } 1324 } 1325 1326 title.textContent = 'Edit Event'; 1327 dialog.style.display = 'flex'; 1328 1329 // Propagate CSS vars to dialog 1330 propagateThemeVars(calId, dialog); 1331 } 1332 }) 1333 .catch(err => console.error('Error editing event:', err)); 1334}; 1335 1336// Delete event 1337window.deleteEvent = function(calId, eventId, date, namespace) { 1338 if (!confirm('Delete this event?')) return; 1339 1340 const params = new URLSearchParams({ 1341 call: 'plugin_calendar', 1342 action: 'delete_event', 1343 namespace: namespace, 1344 date: date, 1345 eventId: eventId, 1346 sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : '' 1347 }); 1348 1349 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1350 method: 'POST', 1351 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1352 body: params.toString() 1353 }) 1354 .then(r => r.json()) 1355 .then(data => { 1356 if (data.success) { 1357 // Extract year and month from date 1358 const [year, month] = date.split('-').map(Number); 1359 1360 // Reload calendar data via AJAX 1361 reloadCalendarData(calId, year, month, namespace); 1362 } 1363 }) 1364 .catch(err => console.error('Error:', err)); 1365}; 1366 1367// Save event (add or edit) 1368window.saveEventCompact = function(calId, namespace) { 1369 const form = document.getElementById('eventform-' + calId); 1370 1371 // Get namespace from dropdown - this is what the user selected 1372 const namespaceSelect = document.getElementById('event-namespace-' + calId); 1373 const selectedNamespace = namespaceSelect ? namespaceSelect.value : ''; 1374 1375 // ALWAYS use what the user selected in the dropdown 1376 // This allows changing namespace when editing 1377 const finalNamespace = selectedNamespace; 1378 1379 const eventId = document.getElementById('event-id-' + calId).value; 1380 1381 // eventNamespace is the ORIGINAL namespace (only used for finding/deleting old event) 1382 const originalNamespace = form.dataset.eventNamespace; 1383 1384 1385 const dateInput = document.getElementById('event-date-' + calId); 1386 const date = dateInput.value; 1387 const oldDate = dateInput.getAttribute('data-original-date') || date; 1388 const endDate = document.getElementById('event-end-date-' + calId).value; 1389 const title = document.getElementById('event-title-' + calId).value; 1390 const time = document.getElementById('event-time-' + calId).value; 1391 const endTime = document.getElementById('event-end-time-' + calId).value; 1392 const colorSelect = document.getElementById('event-color-' + calId); 1393 let color = colorSelect.value; 1394 1395 // Handle custom color 1396 if (color === 'custom') { 1397 color = colorSelect.dataset.customColor || document.getElementById('event-color-custom-' + calId).value; 1398 } 1399 1400 const description = document.getElementById('event-desc-' + calId).value; 1401 const isTask = document.getElementById('event-is-task-' + calId).checked; 1402 const completed = false; // New tasks are not completed 1403 const isRecurring = document.getElementById('event-recurring-' + calId).checked; 1404 const recurrenceType = document.getElementById('event-recurrence-type-' + calId).value; 1405 const recurrenceEnd = document.getElementById('event-recurrence-end-' + calId).value; 1406 1407 if (!title) { 1408 alert('Please enter a title'); 1409 return; 1410 } 1411 1412 if (!date) { 1413 alert('Please select a date'); 1414 return; 1415 } 1416 1417 const params = new URLSearchParams({ 1418 call: 'plugin_calendar', 1419 action: 'save_event', 1420 namespace: finalNamespace, 1421 eventId: eventId, 1422 date: date, 1423 oldDate: oldDate, 1424 endDate: endDate, 1425 title: title, 1426 time: time, 1427 endTime: endTime, 1428 color: color, 1429 description: description, 1430 isTask: isTask ? '1' : '0', 1431 completed: completed ? '1' : '0', 1432 isRecurring: isRecurring ? '1' : '0', 1433 recurrenceType: recurrenceType, 1434 recurrenceEnd: recurrenceEnd, 1435 sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : '' 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 1521 // Find the event item in the event list 1522 const eventList = document.querySelector('#' + calId + ' .event-list-compact'); 1523 if (!eventList) { 1524 return; 1525 } 1526 1527 const eventItem = eventList.querySelector('[data-event-id="' + eventId + '"][data-date="' + date + '"]'); 1528 if (!eventItem) { 1529 return; 1530 } 1531 1532 1533 // Get theme 1534 const container = document.getElementById(calId); 1535 const theme = container ? container.dataset.theme : 'matrix'; 1536 const themeStyles = container ? JSON.parse(container.dataset.themeStyles || '{}') : {}; 1537 1538 1539 // Theme-specific highlight colors 1540 let highlightBg, highlightShadow; 1541 if (theme === 'matrix') { 1542 highlightBg = '#1a3d1a'; // Darker green 1543 highlightShadow = '0 0 20px rgba(0, 204, 7, 0.8), 0 0 40px rgba(0, 204, 7, 0.4)'; 1544 } else if (theme === 'purple') { 1545 highlightBg = '#3d2b4d'; // Darker purple 1546 highlightShadow = '0 0 20px rgba(155, 89, 182, 0.8), 0 0 40px rgba(155, 89, 182, 0.4)'; 1547 } else if (theme === 'professional') { 1548 highlightBg = '#e3f2fd'; // Light blue 1549 highlightShadow = '0 0 20px rgba(74, 144, 226, 0.4)'; 1550 } else if (theme === 'pink') { 1551 highlightBg = '#3d2030'; // Darker pink 1552 highlightShadow = '0 0 20px rgba(255, 20, 147, 0.8), 0 0 40px rgba(255, 20, 147, 0.4)'; 1553 } else if (theme === 'wiki') { 1554 highlightBg = themeStyles.header_bg || '#e8e8e8'; // __background_alt__ 1555 highlightShadow = '0 0 10px rgba(0, 0, 0, 0.15)'; 1556 } 1557 1558 1559 // Store original styles 1560 const originalBg = eventItem.style.background; 1561 const originalShadow = eventItem.style.boxShadow; 1562 1563 // Remove previous highlights (restore their original styles) 1564 const previousHighlights = eventList.querySelectorAll('.event-highlighted'); 1565 previousHighlights.forEach(el => { 1566 el.classList.remove('event-highlighted'); 1567 }); 1568 1569 // Add highlight class and apply theme-aware glow 1570 eventItem.classList.add('event-highlighted'); 1571 1572 // Set CSS properties directly 1573 eventItem.style.setProperty('background', highlightBg, 'important'); 1574 eventItem.style.setProperty('box-shadow', highlightShadow, 'important'); 1575 eventItem.style.setProperty('transition', 'all 0.3s ease-in-out', 'important'); 1576 1577 1578 // Scroll to event 1579 eventItem.scrollIntoView({ 1580 behavior: 'smooth', 1581 block: 'nearest', 1582 inline: 'nearest' 1583 }); 1584 1585 // Remove highlight after 3 seconds and restore original styles 1586 setTimeout(() => { 1587 eventItem.classList.remove('event-highlighted'); 1588 eventItem.style.setProperty('background', originalBg); 1589 eventItem.style.setProperty('box-shadow', originalShadow); 1590 eventItem.style.setProperty('transition', ''); 1591 }, 3000); 1592}; 1593 1594// Toggle recurring event options 1595window.toggleRecurringOptions = function(calId) { 1596 const checkbox = document.getElementById('event-recurring-' + calId); 1597 const options = document.getElementById('recurring-options-' + calId); 1598 1599 if (checkbox && options) { 1600 options.style.display = checkbox.checked ? 'block' : 'none'; 1601 } 1602}; 1603 1604// ============================================================ 1605// Document-level event delegation (guarded - only attach once) 1606// These use event delegation so they work for AJAX-rebuilt content. 1607// ============================================================ 1608if (!window._calendarDelegationInit) { 1609 window._calendarDelegationInit = true; 1610 1611 // ESC closes dialogs, popups, tooltips 1612 document.addEventListener('keydown', function(e) { 1613 if (e.key === 'Escape') { 1614 document.querySelectorAll('.event-dialog-compact').forEach(function(d) { 1615 if (d.style.display === 'flex') d.style.display = 'none'; 1616 }); 1617 document.querySelectorAll('.day-popup').forEach(function(p) { 1618 p.style.display = 'none'; 1619 }); 1620 hideConflictTooltip(); 1621 } 1622 }); 1623 1624 // Conflict tooltip delegation (capture phase for mouseenter/leave) 1625 document.addEventListener('mouseenter', function(e) { 1626 if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) { 1627 showConflictTooltip(e.target); 1628 } 1629 }, true); 1630 1631 document.addEventListener('mouseleave', function(e) { 1632 if (e.target && e.target.classList && e.target.classList.contains('event-conflict-badge')) { 1633 hideConflictTooltip(); 1634 } 1635 }, true); 1636} // end delegation guard 1637 1638// Event panel navigation 1639window.navEventPanel = function(calId, year, month, namespace) { 1640 const params = new URLSearchParams({ 1641 call: 'plugin_calendar', 1642 action: 'load_month', 1643 year: year, 1644 month: month, 1645 namespace: namespace, 1646 _: new Date().getTime() // Cache buster 1647 }); 1648 1649 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1650 method: 'POST', 1651 headers: { 1652 'Content-Type': 'application/x-www-form-urlencoded', 1653 'Cache-Control': 'no-cache, no-store, must-revalidate', 1654 'Pragma': 'no-cache' 1655 }, 1656 body: params.toString() 1657 }) 1658 .then(r => r.json()) 1659 .then(data => { 1660 if (data.success) { 1661 rebuildEventPanel(calId, data.year, data.month, data.events, namespace); 1662 } 1663 }) 1664 .catch(err => console.error('Error:', err)); 1665}; 1666 1667// Rebuild event panel only 1668window.rebuildEventPanel = function(calId, year, month, events, namespace) { 1669 const container = document.getElementById(calId); 1670 const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 1671 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 1672 1673 // Update month title in new compact header 1674 const monthTitle = container.querySelector('.panel-month-title'); 1675 if (monthTitle) { 1676 monthTitle.textContent = monthNames[month - 1] + ' ' + year; 1677 monthTitle.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1678 monthTitle.setAttribute('title', 'Click to jump to month'); 1679 } 1680 1681 // Fallback: Update old header format if exists 1682 const oldHeader = container.querySelector('.panel-standalone-header h3, .calendar-month-picker'); 1683 if (oldHeader && !monthTitle) { 1684 oldHeader.textContent = monthNames[month - 1] + ' ' + year + ' Events'; 1685 oldHeader.setAttribute('onclick', `openMonthPickerPanel('${calId}', ${year}, ${month}, '${namespace}')`); 1686 } 1687 1688 // Update nav buttons 1689 let prevMonth = month - 1; 1690 let prevYear = year; 1691 if (prevMonth < 1) { 1692 prevMonth = 12; 1693 prevYear--; 1694 } 1695 1696 let nextMonth = month + 1; 1697 let nextYear = year; 1698 if (nextMonth > 12) { 1699 nextMonth = 1; 1700 nextYear++; 1701 } 1702 1703 // Update new compact nav buttons 1704 const navBtns = container.querySelectorAll('.panel-nav-btn'); 1705 if (navBtns[0]) navBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 1706 if (navBtns[1]) navBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 1707 1708 // Fallback for old nav buttons 1709 const oldNavBtns = container.querySelectorAll('.cal-nav-btn'); 1710 if (oldNavBtns.length > 0 && navBtns.length === 0) { 1711 if (oldNavBtns[0]) oldNavBtns[0].setAttribute('onclick', `navEventPanel('${calId}', ${prevYear}, ${prevMonth}, '${namespace}')`); 1712 if (oldNavBtns[1]) oldNavBtns[1].setAttribute('onclick', `navEventPanel('${calId}', ${nextYear}, ${nextMonth}, '${namespace}')`); 1713 } 1714 1715 // Update Today button (works for both old and new) 1716 const todayBtn = container.querySelector('.panel-today-btn, .cal-today-btn, .cal-today-btn-compact'); 1717 if (todayBtn) { 1718 todayBtn.setAttribute('onclick', `jumpTodayPanel('${calId}', '${namespace}')`); 1719 } 1720 1721 // Rebuild event list 1722 const eventList = container.querySelector('.event-list-compact'); 1723 if (eventList) { 1724 eventList.innerHTML = renderEventListFromData(events, calId, namespace, year, month); 1725 } 1726}; 1727 1728// Open add event for panel 1729window.openAddEventPanel = function(calId, namespace) { 1730 const today = new Date(); 1731 const year = today.getFullYear(); 1732 const month = String(today.getMonth() + 1).padStart(2, '0'); 1733 const day = String(today.getDate()).padStart(2, '0'); 1734 const localDate = `${year}-${month}-${day}`; 1735 openAddEvent(calId, namespace, localDate); 1736}; 1737 1738// Toggle task completion 1739window.toggleTaskComplete = function(calId, eventId, date, namespace, completed) { 1740 const params = new URLSearchParams({ 1741 call: 'plugin_calendar', 1742 action: 'toggle_task', 1743 namespace: namespace, 1744 date: date, 1745 eventId: eventId, 1746 completed: completed ? '1' : '0', 1747 sectok: typeof JSINFO !== 'undefined' ? JSINFO.sectok : '' 1748 }); 1749 1750 fetch(DOKU_BASE + 'lib/exe/ajax.php', { 1751 method: 'POST', 1752 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 1753 body: params.toString() 1754 }) 1755 .then(r => r.json()) 1756 .then(data => { 1757 if (data.success) { 1758 const [year, month] = date.split('-').map(Number); 1759 reloadCalendarData(calId, year, month, namespace); 1760 } 1761 }) 1762 .catch(err => console.error('Error toggling task:', err)); 1763}; 1764 1765// Make dialog draggable 1766window.makeDialogDraggable = function(calId) { 1767 const dialog = document.getElementById('dialog-content-' + calId); 1768 const handle = document.getElementById('drag-handle-' + calId); 1769 1770 if (!dialog || !handle) return; 1771 1772 let isDragging = false; 1773 let currentX; 1774 let currentY; 1775 let initialX; 1776 let initialY; 1777 let xOffset = 0; 1778 let yOffset = 0; 1779 1780 handle.addEventListener('mousedown', dragStart); 1781 document.addEventListener('mousemove', drag); 1782 document.addEventListener('mouseup', dragEnd); 1783 1784 function dragStart(e) { 1785 initialX = e.clientX - xOffset; 1786 initialY = e.clientY - yOffset; 1787 isDragging = true; 1788 } 1789 1790 function drag(e) { 1791 if (isDragging) { 1792 e.preventDefault(); 1793 currentX = e.clientX - initialX; 1794 currentY = e.clientY - initialY; 1795 xOffset = currentX; 1796 yOffset = currentY; 1797 setTranslate(currentX, currentY, dialog); 1798 } 1799 } 1800 1801 function dragEnd(e) { 1802 initialX = currentX; 1803 initialY = currentY; 1804 isDragging = false; 1805 } 1806 1807 function setTranslate(xPos, yPos, el) { 1808 el.style.transform = `translate(${xPos}px, ${yPos}px)`; 1809 } 1810}; 1811 1812// Initialize dialog draggability when opened (avoid duplicate declaration) 1813if (!window.calendarDraggabilityPatched) { 1814 window.calendarDraggabilityPatched = true; 1815 1816 const originalOpenAddEvent = openAddEvent; 1817 openAddEvent = function(calId, namespace, date) { 1818 originalOpenAddEvent(calId, namespace, date); 1819 setTimeout(() => makeDialogDraggable(calId), 100); 1820 }; 1821 1822 const originalEditEvent = editEvent; 1823 editEvent = function(calId, eventId, date, namespace) { 1824 originalEditEvent(calId, eventId, date, namespace); 1825 setTimeout(() => makeDialogDraggable(calId), 100); 1826 }; 1827} 1828 1829// Toggle expand/collapse for past events 1830window.togglePastEventExpand = function(element) { 1831 // Stop propagation to prevent any parent click handlers 1832 event.stopPropagation(); 1833 1834 const meta = element.querySelector(".event-meta-compact"); 1835 const desc = element.querySelector(".event-desc-compact"); 1836 1837 // Toggle visibility 1838 if (meta.style.display === "none") { 1839 // Expand 1840 meta.style.display = "block"; 1841 if (desc) desc.style.display = "block"; 1842 element.classList.add("event-past-expanded"); 1843 } else { 1844 // Collapse 1845 meta.style.display = "none"; 1846 if (desc) desc.style.display = "none"; 1847 element.classList.remove("event-past-expanded"); 1848 } 1849}; 1850 1851// Filter calendar by namespace when clicking namespace badge (guarded) 1852if (!window._calendarClickDelegationInit) { 1853 window._calendarClickDelegationInit = true; 1854 document.addEventListener('click', function(e) { 1855 if (e.target.classList.contains('event-namespace-badge')) { 1856 const namespace = e.target.textContent; 1857 const calendar = e.target.closest('.calendar-compact-container'); 1858 1859 if (!calendar) return; 1860 1861 const calId = calendar.id; 1862 1863 // Use AJAX reload to filter both calendar grid and event list 1864 filterCalendarByNamespace(calId, namespace); 1865 } 1866 }); 1867} // end click delegation guard 1868 1869// Update the displayed filtered namespace in event list header 1870// Legacy badge removed - namespace filtering still works but badge no longer shown 1871window.updateFilteredNamespaceDisplay = function(calId, namespace) { 1872 const calendar = document.getElementById(calId); 1873 if (!calendar) return; 1874 1875 const headerContent = calendar.querySelector('.event-list-header-content'); 1876 if (!headerContent) return; 1877 1878 // Remove any existing filter badge (cleanup) 1879 let filterBadge = headerContent.querySelector('.namespace-filter-badge'); 1880 if (filterBadge) { 1881 filterBadge.remove(); 1882 } 1883}; 1884 1885// Clear namespace filter 1886window.clearNamespaceFilter = function(calId) { 1887 1888 const container = document.getElementById(calId); 1889 if (!container) { 1890 console.error('Calendar container not found:', calId); 1891 return; 1892 } 1893 1894 // Immediately hide/remove the filter badge 1895 const filterBadge = container.querySelector('.calendar-namespace-filter'); 1896 if (filterBadge) { 1897 filterBadge.style.display = 'none'; 1898 filterBadge.remove(); 1899 } 1900 1901 // Get current year and month 1902 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 1903 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 1904 1905 // Get original namespace (what the calendar was initialized with) 1906 const originalNamespace = container.dataset.originalNamespace || ''; 1907 1908 // Also check for sidebar widget 1909 const sidebarContainer = document.getElementById('sidebar-widget-' + calId); 1910 if (sidebarContainer) { 1911 // For sidebar widget, just reload the page without namespace filter 1912 // Remove the namespace from the URL and reload 1913 const url = new URL(window.location.href); 1914 url.searchParams.delete('namespace'); 1915 window.location.href = url.toString(); 1916 return; 1917 } 1918 1919 // For regular calendar, reload calendar with original namespace 1920 navCalendar(calId, year, month, originalNamespace); 1921}; 1922 1923window.clearNamespaceFilterPanel = function(calId) { 1924 1925 const container = document.getElementById(calId); 1926 if (!container) { 1927 console.error('Event panel container not found:', calId); 1928 return; 1929 } 1930 1931 // Get current year and month from URL params or container 1932 const year = parseInt(container.dataset.year) || new Date().getFullYear(); 1933 const month = parseInt(container.dataset.month) || (new Date().getMonth() + 1); 1934 1935 // Get original namespace (what the panel was initialized with) 1936 const originalNamespace = container.dataset.originalNamespace || ''; 1937 1938 1939 // Reload event panel with original namespace 1940 navEventPanel(calId, year, month, originalNamespace); 1941}; 1942 1943// Color picker functions 1944window.updateCustomColorPicker = function(calId) { 1945 const select = document.getElementById('event-color-' + calId); 1946 const picker = document.getElementById('event-color-custom-' + calId); 1947 1948 if (select.value === 'custom') { 1949 // Show color picker 1950 picker.style.display = 'inline-block'; 1951 picker.click(); // Open color picker 1952 } else { 1953 // Hide color picker and sync value 1954 picker.style.display = 'none'; 1955 picker.value = select.value; 1956 } 1957}; 1958 1959function updateColorFromPicker(calId) { 1960 const select = document.getElementById('event-color-' + calId); 1961 const picker = document.getElementById('event-color-custom-' + calId); 1962 1963 // Set select to custom and update its underlying value 1964 select.value = 'custom'; 1965 // Store the actual color value in a data attribute 1966 select.dataset.customColor = picker.value; 1967} 1968 1969// Toggle past events visibility 1970window.togglePastEvents = function(calId) { 1971 const content = document.getElementById('past-events-' + calId); 1972 const arrow = document.getElementById('past-arrow-' + calId); 1973 1974 if (!content || !arrow) { 1975 console.error('Past events elements not found for:', calId); 1976 return; 1977 } 1978 1979 // Check computed style instead of inline style 1980 const isHidden = window.getComputedStyle(content).display === 'none'; 1981 1982 if (isHidden) { 1983 content.style.display = 'block'; 1984 arrow.textContent = '▼'; 1985 } else { 1986 content.style.display = 'none'; 1987 arrow.textContent = '▶'; 1988 } 1989}; 1990 1991// Fuzzy match scoring function 1992window.fuzzyMatch = function(pattern, str) { 1993 pattern = pattern.toLowerCase(); 1994 str = str.toLowerCase(); 1995 1996 let patternIdx = 0; 1997 let score = 0; 1998 let consecutiveMatches = 0; 1999 2000 for (let i = 0; i < str.length; i++) { 2001 if (patternIdx < pattern.length && str[i] === pattern[patternIdx]) { 2002 score += 1 + consecutiveMatches; 2003 consecutiveMatches++; 2004 patternIdx++; 2005 } else { 2006 consecutiveMatches = 0; 2007 } 2008 } 2009 2010 // Return null if not all characters matched 2011 if (patternIdx !== pattern.length) { 2012 return null; 2013 } 2014 2015 // Bonus for exact match 2016 if (str === pattern) { 2017 score += 100; 2018 } 2019 2020 // Bonus for starts with 2021 if (str.startsWith(pattern)) { 2022 score += 50; 2023 } 2024 2025 return score; 2026}; 2027 2028// Initialize namespace search for a calendar 2029window.initNamespaceSearch = function(calId) { 2030 const searchInput = document.getElementById('event-namespace-search-' + calId); 2031 const hiddenInput = document.getElementById('event-namespace-' + calId); 2032 const dropdown = document.getElementById('event-namespace-dropdown-' + calId); 2033 const dataElement = document.getElementById('namespaces-data-' + calId); 2034 2035 if (!searchInput || !hiddenInput || !dropdown || !dataElement) { 2036 return; // Elements not found 2037 } 2038 2039 let namespaces = []; 2040 try { 2041 namespaces = JSON.parse(dataElement.textContent); 2042 } catch (e) { 2043 console.error('Failed to parse namespaces data:', e); 2044 return; 2045 } 2046 2047 let selectedIndex = -1; 2048 2049 // Filter and show dropdown 2050 function filterNamespaces(query) { 2051 if (!query || query.trim() === '') { 2052 // Show all namespaces when empty 2053 hiddenInput.value = ''; 2054 const results = namespaces.slice(0, 20); // Limit to 20 2055 showDropdown(results); 2056 return; 2057 } 2058 2059 // Fuzzy match and score 2060 const matches = []; 2061 for (let i = 0; i < namespaces.length; i++) { 2062 const score = fuzzyMatch(query, namespaces[i]); 2063 if (score !== null) { 2064 matches.push({ namespace: namespaces[i], score: score }); 2065 } 2066 } 2067 2068 // Sort by score (descending) 2069 matches.sort((a, b) => b.score - a.score); 2070 2071 // Take top 20 results 2072 const results = matches.slice(0, 20).map(m => m.namespace); 2073 showDropdown(results); 2074 } 2075 2076 function showDropdown(results) { 2077 dropdown.innerHTML = ''; 2078 selectedIndex = -1; 2079 2080 if (results.length === 0) { 2081 dropdown.style.display = 'none'; 2082 return; 2083 } 2084 2085 // Add (default) option 2086 const defaultOption = document.createElement('div'); 2087 defaultOption.className = 'namespace-option'; 2088 defaultOption.textContent = '(default)'; 2089 defaultOption.dataset.value = ''; 2090 dropdown.appendChild(defaultOption); 2091 2092 results.forEach(ns => { 2093 const option = document.createElement('div'); 2094 option.className = 'namespace-option'; 2095 option.textContent = ns; 2096 option.dataset.value = ns; 2097 dropdown.appendChild(option); 2098 }); 2099 2100 dropdown.style.display = 'block'; 2101 } 2102 2103 function hideDropdown() { 2104 dropdown.style.display = 'none'; 2105 selectedIndex = -1; 2106 } 2107 2108 function selectOption(namespace) { 2109 hiddenInput.value = namespace; 2110 searchInput.value = namespace || '(default)'; 2111 hideDropdown(); 2112 } 2113 2114 // Event listeners 2115 searchInput.addEventListener('input', function(e) { 2116 filterNamespaces(e.target.value); 2117 }); 2118 2119 searchInput.addEventListener('focus', function(e) { 2120 filterNamespaces(e.target.value); 2121 }); 2122 2123 searchInput.addEventListener('blur', function(e) { 2124 // Delay to allow click on dropdown 2125 setTimeout(hideDropdown, 200); 2126 }); 2127 2128 searchInput.addEventListener('keydown', function(e) { 2129 const options = dropdown.querySelectorAll('.namespace-option'); 2130 2131 if (e.key === 'ArrowDown') { 2132 e.preventDefault(); 2133 selectedIndex = Math.min(selectedIndex + 1, options.length - 1); 2134 updateSelection(options); 2135 } else if (e.key === 'ArrowUp') { 2136 e.preventDefault(); 2137 selectedIndex = Math.max(selectedIndex - 1, -1); 2138 updateSelection(options); 2139 } else if (e.key === 'Enter') { 2140 e.preventDefault(); 2141 if (selectedIndex >= 0 && options[selectedIndex]) { 2142 selectOption(options[selectedIndex].dataset.value); 2143 } 2144 } else if (e.key === 'Escape') { 2145 hideDropdown(); 2146 } 2147 }); 2148 2149 function updateSelection(options) { 2150 options.forEach((opt, idx) => { 2151 if (idx === selectedIndex) { 2152 opt.classList.add('selected'); 2153 opt.scrollIntoView({ block: 'nearest' }); 2154 } else { 2155 opt.classList.remove('selected'); 2156 } 2157 }); 2158 } 2159 2160 // Click on dropdown option 2161 dropdown.addEventListener('mousedown', function(e) { 2162 if (e.target.classList.contains('namespace-option')) { 2163 selectOption(e.target.dataset.value); 2164 } 2165 }); 2166}; 2167 2168// Update end time options based on start time selection 2169window.updateEndTimeOptions = function(calId) { 2170 const startTimeSelect = document.getElementById('event-time-' + calId); 2171 const endTimeSelect = document.getElementById('event-end-time-' + calId); 2172 2173 if (!startTimeSelect || !endTimeSelect) return; 2174 2175 const startTime = startTimeSelect.value; 2176 2177 // If start time is empty (all day), disable end time 2178 if (!startTime) { 2179 endTimeSelect.disabled = true; 2180 endTimeSelect.value = ''; 2181 return; 2182 } 2183 2184 // Enable end time select 2185 endTimeSelect.disabled = false; 2186 2187 // Convert start time to minutes 2188 const startMinutes = timeToMinutes(startTime); 2189 2190 // Get current end time value (to preserve if valid) 2191 const currentEndTime = endTimeSelect.value; 2192 const currentEndMinutes = currentEndTime ? timeToMinutes(currentEndTime) : 0; 2193 2194 // Filter options - show only times after start time 2195 const options = endTimeSelect.options; 2196 let firstValidOption = null; 2197 let currentStillValid = false; 2198 2199 for (let i = 0; i < options.length; i++) { 2200 const option = options[i]; 2201 const optionValue = option.value; 2202 2203 if (optionValue === '') { 2204 // Keep "Same as start" option visible 2205 option.style.display = ''; 2206 continue; 2207 } 2208 2209 const optionMinutes = timeToMinutes(optionValue); 2210 2211 if (optionMinutes > startMinutes) { 2212 // Show options after start time 2213 option.style.display = ''; 2214 if (!firstValidOption) { 2215 firstValidOption = optionValue; 2216 } 2217 if (optionValue === currentEndTime) { 2218 currentStillValid = true; 2219 } 2220 } else { 2221 // Hide options before or equal to start time 2222 option.style.display = 'none'; 2223 } 2224 } 2225 2226 // If current end time is now invalid, set a new one 2227 if (!currentStillValid || currentEndMinutes <= startMinutes) { 2228 // Try to set to 1 hour after start 2229 const [startHour, startMinute] = startTime.split(':').map(Number); 2230 let endHour = startHour + 1; 2231 let endMinute = startMinute; 2232 2233 if (endHour >= 24) { 2234 endHour = 23; 2235 endMinute = 45; 2236 } 2237 2238 const suggestedEndTime = String(endHour).padStart(2, '0') + ':' + String(endMinute).padStart(2, '0'); 2239 2240 // Check if suggested time is in the list 2241 const suggestedExists = Array.from(options).some(opt => opt.value === suggestedEndTime); 2242 2243 if (suggestedExists) { 2244 endTimeSelect.value = suggestedEndTime; 2245 } else if (firstValidOption) { 2246 // Use first valid option 2247 endTimeSelect.value = firstValidOption; 2248 } else { 2249 // No valid options (shouldn't happen, but just in case) 2250 endTimeSelect.value = ''; 2251 } 2252 } 2253}; 2254 2255// Check for time conflicts between events on the same date 2256window.checkTimeConflicts = function(events, currentEventId) { 2257 const conflicts = []; 2258 2259 // Group events by date 2260 const eventsByDate = {}; 2261 for (const [date, dateEvents] of Object.entries(events)) { 2262 if (!Array.isArray(dateEvents)) continue; 2263 2264 dateEvents.forEach(evt => { 2265 if (!evt.time || evt.id === currentEventId) return; // Skip all-day events and current event 2266 2267 if (!eventsByDate[date]) eventsByDate[date] = []; 2268 eventsByDate[date].push(evt); 2269 }); 2270 } 2271 2272 // Check for overlaps on each date 2273 for (const [date, dateEvents] of Object.entries(eventsByDate)) { 2274 for (let i = 0; i < dateEvents.length; i++) { 2275 for (let j = i + 1; j < dateEvents.length; j++) { 2276 const evt1 = dateEvents[i]; 2277 const evt2 = dateEvents[j]; 2278 2279 if (eventsOverlap(evt1, evt2)) { 2280 // Mark both events as conflicting 2281 if (!evt1.hasConflict) evt1.hasConflict = true; 2282 if (!evt2.hasConflict) evt2.hasConflict = true; 2283 2284 // Store conflict info 2285 if (!evt1.conflictsWith) evt1.conflictsWith = []; 2286 if (!evt2.conflictsWith) evt2.conflictsWith = []; 2287 2288 evt1.conflictsWith.push({id: evt2.id, title: evt2.title, time: evt2.time, endTime: evt2.endTime}); 2289 evt2.conflictsWith.push({id: evt1.id, title: evt1.title, time: evt1.time, endTime: evt1.endTime}); 2290 } 2291 } 2292 } 2293 } 2294 2295 return events; 2296}; 2297 2298// Check if two events overlap in time 2299function eventsOverlap(evt1, evt2) { 2300 if (!evt1.time || !evt2.time) return false; // All-day events don't conflict 2301 2302 const start1 = evt1.time; 2303 const end1 = evt1.endTime || evt1.time; // If no end time, treat as same as start 2304 2305 const start2 = evt2.time; 2306 const end2 = evt2.endTime || evt2.time; 2307 2308 // Convert to minutes for easier comparison 2309 const start1Mins = timeToMinutes(start1); 2310 const end1Mins = timeToMinutes(end1); 2311 const start2Mins = timeToMinutes(start2); 2312 const end2Mins = timeToMinutes(end2); 2313 2314 // Check for overlap 2315 // Events overlap if: start1 < end2 AND start2 < end1 2316 return start1Mins < end2Mins && start2Mins < end1Mins; 2317} 2318 2319// Convert HH:MM time to minutes since midnight 2320function timeToMinutes(timeStr) { 2321 const [hours, minutes] = timeStr.split(':').map(Number); 2322 return hours * 60 + minutes; 2323} 2324 2325// Format time range for display 2326window.formatTimeRange = function(startTime, endTime) { 2327 if (!startTime) return ''; 2328 2329 const formatTime = (timeStr) => { 2330 const [hour24, minute] = timeStr.split(':').map(Number); 2331 const hour12 = hour24 === 0 ? 12 : (hour24 > 12 ? hour24 - 12 : hour24); 2332 const ampm = hour24 < 12 ? 'AM' : 'PM'; 2333 return hour12 + ':' + String(minute).padStart(2, '0') + ' ' + ampm; 2334 }; 2335 2336 if (!endTime || endTime === startTime) { 2337 return formatTime(startTime); 2338 } 2339 2340 return formatTime(startTime) + ' - ' + formatTime(endTime); 2341}; 2342 2343// Track last known mouse position for tooltip positioning fallback 2344var _lastMouseX = 0, _lastMouseY = 0; 2345document.addEventListener('mousemove', function(e) { 2346 _lastMouseX = e.clientX; 2347 _lastMouseY = e.clientY; 2348}); 2349 2350// Show custom conflict tooltip 2351window.showConflictTooltip = function(badgeElement) { 2352 // Remove any existing tooltip 2353 hideConflictTooltip(); 2354 2355 // Get conflict data (base64-encoded JSON to avoid attribute quote issues) 2356 const conflictsRaw = badgeElement.getAttribute('data-conflicts'); 2357 if (!conflictsRaw) return; 2358 2359 let conflicts; 2360 try { 2361 conflicts = JSON.parse(decodeURIComponent(escape(atob(conflictsRaw)))); 2362 } catch (e) { 2363 // Fallback: try parsing as plain JSON (for PHP-rendered badges) 2364 try { 2365 conflicts = JSON.parse(conflictsRaw); 2366 } catch (e2) { 2367 console.error('Failed to parse conflicts:', e2); 2368 return; 2369 } 2370 } 2371 2372 // Get theme from the calendar container via CSS variables 2373 // Try closest ancestor first, then fall back to any calendar on the page 2374 let containerEl = badgeElement.closest('[id^="cal_"], [id^="panel_"], [id^="sidebar-widget-"], .calendar-compact-container, .event-panel-standalone'); 2375 if (!containerEl) { 2376 // Badge might be inside a day popup (appended to body) - find any calendar container 2377 containerEl = document.querySelector('.calendar-compact-container, .event-panel-standalone, [id^="sidebar-widget-"]'); 2378 } 2379 const cs = containerEl ? getComputedStyle(containerEl) : null; 2380 2381 const bg = cs ? cs.getPropertyValue('--background-site').trim() || '#242424' : '#242424'; 2382 const border = cs ? cs.getPropertyValue('--border-main').trim() || '#00cc07' : '#00cc07'; 2383 const textPrimary = cs ? cs.getPropertyValue('--text-primary').trim() || '#00cc07' : '#00cc07'; 2384 const textDim = cs ? cs.getPropertyValue('--text-dim').trim() || '#00aa00' : '#00aa00'; 2385 const shadow = cs ? cs.getPropertyValue('--shadow-color').trim() || 'rgba(0, 204, 7, 0.3)' : 'rgba(0, 204, 7, 0.3)'; 2386 2387 // Create tooltip 2388 const tooltip = document.createElement('div'); 2389 tooltip.id = 'conflict-tooltip'; 2390 tooltip.className = 'conflict-tooltip'; 2391 2392 // Apply theme styles 2393 tooltip.style.background = bg; 2394 tooltip.style.borderColor = border; 2395 tooltip.style.color = textPrimary; 2396 tooltip.style.boxShadow = '0 4px 12px ' + shadow; 2397 2398 // Build content with themed colors 2399 let html = '<div class="conflict-tooltip-header" style="background: ' + border + '; color: ' + bg + '; border-bottom: 1px solid ' + border + ';">⚠️ Time Conflicts</div>'; 2400 html += '<div class="conflict-tooltip-body">'; 2401 conflicts.forEach(conflict => { 2402 html += '<div class="conflict-item" style="color: ' + textDim + '; border-bottom-color: ' + border + ';">• ' + escapeHtml(conflict) + '</div>'; 2403 }); 2404 html += '</div>'; 2405 2406 tooltip.innerHTML = html; 2407 document.body.appendChild(tooltip); 2408 2409 // Position tooltip 2410 const rect = badgeElement.getBoundingClientRect(); 2411 const tooltipRect = tooltip.getBoundingClientRect(); 2412 2413 // Position above the badge, centered 2414 let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); 2415 let top = rect.top - tooltipRect.height - 8; 2416 2417 // Keep tooltip within viewport 2418 if (left < 10) left = 10; 2419 if (left + tooltipRect.width > window.innerWidth - 10) { 2420 left = window.innerWidth - tooltipRect.width - 10; 2421 } 2422 if (top < 10) { 2423 // If not enough room above, show below 2424 top = rect.bottom + 8; 2425 } 2426 2427 tooltip.style.left = left + 'px'; 2428 tooltip.style.top = top + 'px'; 2429 tooltip.style.opacity = '1'; 2430}; 2431 2432// Hide conflict tooltip 2433window.hideConflictTooltip = function() { 2434 const tooltip = document.getElementById('conflict-tooltip'); 2435 if (tooltip) { 2436 tooltip.remove(); 2437 } 2438}; 2439 2440// Filter events by search term 2441window.filterEvents = function(calId, searchTerm) { 2442 const eventList = document.getElementById('eventlist-' + calId); 2443 const searchClear = document.getElementById('search-clear-' + calId); 2444 2445 if (!eventList) return; 2446 2447 // Show/hide clear button 2448 if (searchClear) { 2449 searchClear.style.display = searchTerm ? 'block' : 'none'; 2450 } 2451 2452 searchTerm = searchTerm.toLowerCase().trim(); 2453 2454 // Get all event items 2455 const eventItems = eventList.querySelectorAll('.event-compact-item'); 2456 let visibleCount = 0; 2457 let hiddenPastCount = 0; 2458 2459 eventItems.forEach(item => { 2460 const title = item.querySelector('.event-title-compact'); 2461 const description = item.querySelector('.event-desc-compact'); 2462 const dateTime = item.querySelector('.event-date-time'); 2463 2464 // Build searchable text 2465 let searchableText = ''; 2466 if (title) searchableText += title.textContent.toLowerCase() + ' '; 2467 if (description) searchableText += description.textContent.toLowerCase() + ' '; 2468 if (dateTime) searchableText += dateTime.textContent.toLowerCase() + ' '; 2469 2470 // Check if matches search 2471 const matches = !searchTerm || searchableText.includes(searchTerm); 2472 2473 if (matches) { 2474 item.style.display = ''; 2475 visibleCount++; 2476 } else { 2477 item.style.display = 'none'; 2478 // Check if this is a past event 2479 if (item.classList.contains('event-past') || item.classList.contains('event-completed')) { 2480 hiddenPastCount++; 2481 } 2482 } 2483 }); 2484 2485 // Update past events toggle if it exists 2486 const pastToggle = eventList.querySelector('.past-events-toggle'); 2487 const pastLabel = eventList.querySelector('.past-events-label'); 2488 const pastContent = document.getElementById('past-events-' + calId); 2489 2490 if (pastToggle && pastLabel && pastContent) { 2491 const visiblePastEvents = pastContent.querySelectorAll('.event-compact-item:not([style*="display: none"])'); 2492 const totalPastVisible = visiblePastEvents.length; 2493 2494 if (totalPastVisible > 0) { 2495 pastLabel.textContent = `Past Events (${totalPastVisible})`; 2496 pastToggle.style.display = ''; 2497 } else { 2498 pastToggle.style.display = 'none'; 2499 } 2500 } 2501 2502 // Show "no results" message if nothing visible 2503 let noResultsMsg = eventList.querySelector('.no-search-results'); 2504 if (visibleCount === 0 && searchTerm) { 2505 if (!noResultsMsg) { 2506 noResultsMsg = document.createElement('p'); 2507 noResultsMsg.className = 'no-search-results no-events-msg'; 2508 noResultsMsg.textContent = 'No events match your search'; 2509 eventList.appendChild(noResultsMsg); 2510 } 2511 noResultsMsg.style.display = 'block'; 2512 } else if (noResultsMsg) { 2513 noResultsMsg.style.display = 'none'; 2514 } 2515}; 2516 2517// Clear event search 2518window.clearEventSearch = function(calId) { 2519 const searchInput = document.getElementById('event-search-' + calId); 2520 if (searchInput) { 2521 searchInput.value = ''; 2522 filterEvents(calId, ''); 2523 searchInput.focus(); 2524 } 2525}; 2526 2527// ============================================ 2528// PINK THEME - GLOWING PARTICLE EFFECTS 2529// ============================================ 2530 2531// Create glowing pink particle effects for pink theme 2532(function() { 2533 let pinkThemeActive = false; 2534 let trailTimer = null; 2535 let pixelTimer = null; 2536 2537 // Check if pink theme is active 2538 function checkPinkTheme() { 2539 const pinkCalendars = document.querySelectorAll('.calendar-theme-pink'); 2540 pinkThemeActive = pinkCalendars.length > 0; 2541 return pinkThemeActive; 2542 } 2543 2544 // Create trail particle 2545 function createTrailParticle(clientX, clientY) { 2546 if (!pinkThemeActive) return; 2547 2548 const trail = document.createElement('div'); 2549 trail.className = 'pink-cursor-trail'; 2550 trail.style.left = clientX + 'px'; 2551 trail.style.top = clientY + 'px'; 2552 trail.style.animation = 'cursor-trail-fade 0.5s ease-out forwards'; 2553 2554 document.body.appendChild(trail); 2555 2556 setTimeout(function() { 2557 trail.remove(); 2558 }, 500); 2559 } 2560 2561 // Create pixel sparkles 2562 function createPixelSparkles(clientX, clientY) { 2563 if (!pinkThemeActive || pixelTimer) return; 2564 2565 const pixelCount = 3 + Math.floor(Math.random() * 4); // 3-6 pixels 2566 2567 for (let i = 0; i < pixelCount; i++) { 2568 const pixel = document.createElement('div'); 2569 pixel.className = 'pink-pixel-sparkle'; 2570 2571 // Random offset from cursor 2572 const offsetX = (Math.random() - 0.5) * 30; 2573 const offsetY = (Math.random() - 0.5) * 30; 2574 2575 pixel.style.left = (clientX + offsetX) + 'px'; 2576 pixel.style.top = (clientY + offsetY) + 'px'; 2577 2578 // Random color - bright neon pinks and whites 2579 const colors = ['#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1']; 2580 const color = colors[Math.floor(Math.random() * colors.length)]; 2581 pixel.style.background = color; 2582 pixel.style.boxShadow = '0 0 2px ' + color + ', 0 0 4px ' + color + ', 0 0 6px #fff'; 2583 2584 // Random animation 2585 if (Math.random() > 0.5) { 2586 pixel.style.animation = 'pixel-twinkle 0.6s ease-out forwards'; 2587 } else { 2588 pixel.style.animation = 'pixel-float-away 0.8s ease-out forwards'; 2589 } 2590 2591 document.body.appendChild(pixel); 2592 2593 setTimeout(function() { 2594 pixel.remove(); 2595 }, 800); 2596 } 2597 2598 pixelTimer = setTimeout(function() { 2599 pixelTimer = null; 2600 }, 40); 2601 } 2602 2603 // Create explosion 2604 function createExplosion(clientX, clientY) { 2605 if (!pinkThemeActive) return; 2606 2607 const particleCount = 25; 2608 const colors = ['#ff1493', '#ff69b4', '#ff85c1', '#ffc0cb', '#fff']; 2609 2610 // Add hearts to explosion (8-12 hearts) 2611 const heartCount = 8 + Math.floor(Math.random() * 5); 2612 for (let i = 0; i < heartCount; i++) { 2613 const heart = document.createElement('div'); 2614 heart.textContent = ''; 2615 heart.style.position = 'fixed'; 2616 heart.style.left = clientX + 'px'; 2617 heart.style.top = clientY + 'px'; 2618 heart.style.pointerEvents = 'none'; 2619 heart.style.zIndex = '9999999'; 2620 heart.style.fontSize = (12 + Math.random() * 16) + 'px'; 2621 2622 // Random direction 2623 const angle = Math.random() * Math.PI * 2; 2624 const velocity = 60 + Math.random() * 80; 2625 const tx = Math.cos(angle) * velocity; 2626 const ty = Math.sin(angle) * velocity; 2627 2628 heart.style.setProperty('--tx', tx + 'px'); 2629 heart.style.setProperty('--ty', ty + 'px'); 2630 2631 const duration = 0.8 + Math.random() * 0.4; 2632 heart.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 2633 2634 document.body.appendChild(heart); 2635 2636 setTimeout(function() { 2637 heart.remove(); 2638 }, duration * 1000); 2639 } 2640 2641 // Main explosion particles 2642 for (let i = 0; i < particleCount; i++) { 2643 const particle = document.createElement('div'); 2644 particle.className = 'pink-particle'; 2645 2646 const color = colors[Math.floor(Math.random() * colors.length)]; 2647 particle.style.background = 'radial-gradient(circle, ' + color + ', transparent)'; 2648 particle.style.boxShadow = '0 0 10px ' + color + ', 0 0 20px ' + color; 2649 2650 particle.style.left = clientX + 'px'; 2651 particle.style.top = clientY + 'px'; 2652 2653 const angle = (Math.PI * 2 * i) / particleCount; 2654 const velocity = 50 + Math.random() * 100; 2655 const tx = Math.cos(angle) * velocity; 2656 const ty = Math.sin(angle) * velocity; 2657 2658 particle.style.setProperty('--tx', tx + 'px'); 2659 particle.style.setProperty('--ty', ty + 'px'); 2660 2661 const size = 4 + Math.random() * 6; 2662 particle.style.width = size + 'px'; 2663 particle.style.height = size + 'px'; 2664 2665 const duration = 0.6 + Math.random() * 0.4; 2666 particle.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 2667 2668 document.body.appendChild(particle); 2669 2670 setTimeout(function() { 2671 particle.remove(); 2672 }, duration * 1000); 2673 } 2674 2675 // Pixel sparkles 2676 const pixelSparkleCount = 40; 2677 2678 for (let i = 0; i < pixelSparkleCount; i++) { 2679 const pixel = document.createElement('div'); 2680 pixel.className = 'pink-pixel-sparkle'; 2681 2682 const pixelColors = ['#fff', '#fff', '#ff1493', '#ff69b4', '#ffb6c1', '#ff85c1']; 2683 const pixelColor = pixelColors[Math.floor(Math.random() * pixelColors.length)]; 2684 pixel.style.background = pixelColor; 2685 pixel.style.boxShadow = '0 0 3px ' + pixelColor + ', 0 0 6px ' + pixelColor + ', 0 0 9px #fff'; 2686 2687 const angle = Math.random() * Math.PI * 2; 2688 const distance = 30 + Math.random() * 80; 2689 const offsetX = Math.cos(angle) * distance; 2690 const offsetY = Math.sin(angle) * distance; 2691 2692 pixel.style.left = clientX + 'px'; 2693 pixel.style.top = clientY + 'px'; 2694 pixel.style.setProperty('--tx', offsetX + 'px'); 2695 pixel.style.setProperty('--ty', offsetY + 'px'); 2696 2697 const pixelSize = 1 + Math.random() * 2; 2698 pixel.style.width = pixelSize + 'px'; 2699 pixel.style.height = pixelSize + 'px'; 2700 2701 const duration = 0.4 + Math.random() * 0.4; 2702 if (Math.random() > 0.5) { 2703 pixel.style.animation = 'pixel-twinkle ' + duration + 's ease-out forwards'; 2704 } else { 2705 pixel.style.animation = 'particle-explode ' + duration + 's ease-out forwards'; 2706 } 2707 2708 document.body.appendChild(pixel); 2709 2710 setTimeout(function() { 2711 pixel.remove(); 2712 }, duration * 1000); 2713 } 2714 2715 // Flash 2716 const flash = document.createElement('div'); 2717 flash.style.position = 'fixed'; 2718 flash.style.left = clientX + 'px'; 2719 flash.style.top = clientY + 'px'; 2720 flash.style.width = '40px'; 2721 flash.style.height = '40px'; 2722 flash.style.borderRadius = '50%'; 2723 flash.style.background = 'radial-gradient(circle, rgba(255, 255, 255, 0.9), rgba(255, 20, 147, 0.6), transparent)'; 2724 flash.style.boxShadow = '0 0 40px #fff, 0 0 60px #ff1493, 0 0 80px #ff69b4'; 2725 flash.style.pointerEvents = 'none'; 2726 flash.style.zIndex = '9999999'; // Above everything including dialogs 2727 flash.style.transform = 'translate(-50%, -50%)'; 2728 flash.style.animation = 'cursor-trail-fade 0.3s ease-out forwards'; 2729 2730 document.body.appendChild(flash); 2731 2732 setTimeout(function() { 2733 flash.remove(); 2734 }, 300); 2735 } 2736 2737 function initPinkParticles() { 2738 if (!checkPinkTheme()) return; 2739 2740 // Use capture phase to catch events before stopPropagation 2741 document.addEventListener('mousemove', function(e) { 2742 if (!pinkThemeActive) return; 2743 2744 createTrailParticle(e.clientX, e.clientY); 2745 createPixelSparkles(e.clientX, e.clientY); 2746 }, true); // Capture phase! 2747 2748 // Throttle main trail 2749 document.addEventListener('mousemove', function(e) { 2750 if (!pinkThemeActive || trailTimer) return; 2751 2752 trailTimer = setTimeout(function() { 2753 trailTimer = null; 2754 }, 30); 2755 }, true); // Capture phase! 2756 2757 // Click explosion - use capture phase 2758 document.addEventListener('click', function(e) { 2759 if (!pinkThemeActive) return; 2760 2761 createExplosion(e.clientX, e.clientY); 2762 }, true); // Capture phase! 2763 } 2764 2765 // Initialize on load 2766 if (document.readyState === 'loading') { 2767 document.addEventListener('DOMContentLoaded', initPinkParticles); 2768 } else { 2769 initPinkParticles(); 2770 } 2771 2772 // Re-check theme if calendar is dynamically added 2773 if (typeof MutationObserver !== 'undefined') { 2774 const observer = new MutationObserver(function(mutations) { 2775 mutations.forEach(function(mutation) { 2776 if (mutation.addedNodes.length > 0) { 2777 mutation.addedNodes.forEach(function(node) { 2778 if (node.nodeType === 1 && node.classList && node.classList.contains('calendar-theme-pink')) { 2779 checkPinkTheme(); 2780 initPinkParticles(); 2781 } 2782 }); 2783 } 2784 }); 2785 }); 2786 2787 observer.observe(document.body, { 2788 childList: true, 2789 subtree: true 2790 }); 2791 } 2792})(); 2793 2794// End of calendar plugin JavaScript 2795