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