1/** 2 * Homepage Changelog widget and modal. 3 */ 4(function (global) { 5 'use strict'; 6 7 var STORAGE_KEY = 'pupwife_changelog_seen_revision'; 8 var COLLAPSED_DAYS_STORAGE_KEY = 'pupwife_changelog_collapsed_days'; 9 var COLLAPSED_SECTIONS_STORAGE_KEY = 'pupwife_changelog_collapsed_sections'; 10 var CATEGORY_META = { 11 new: { label: 'New', icon: 'fa-plus' }, 12 updated: { label: 'Updated', icon: 'fa-rotate' }, 13 fixed: { label: 'Fixed', icon: 'fa-wrench' }, 14 polished: { label: 'Polished', icon: 'fa-wand-magic-sparkles' }, 15 removed: { label: 'Removed', icon: 'fa-trash-can' } 16 }; 17 var openButton = null; 18 var modal = null; 19 var lastFocused = null; 20 var todoActivityRef = null; 21 var todoActivityHandler = null; 22 var todoEvents = []; 23 24 function getData(opts) { 25 opts = opts || {}; 26 var base = global.PUPWIFE_CHANGELOG || { revision: '', latestDate: '', entries: [] }; 27 if (opts.includeTodoActivity === false) return base; 28 if (!todoEvents.length) return base; 29 30 var entries = (base.entries || []).map(function (entry) { 31 return { 32 date: entry.date, 33 pages: (entry.pages || []).map(function (page) { 34 return { 35 name: page.name, 36 href: page.href, 37 icon: page.icon, 38 groups: (page.groups || []).map(function (group) { 39 return { 40 type: group.type, 41 items: (group.items || []).slice() 42 }; 43 }) 44 }; 45 }) 46 }; 47 }); 48 var byDate = {}; 49 entries.forEach(function (entry) { 50 byDate[entry.date] = entry; 51 }); 52 53 todoEvents.forEach(function (event) { 54 var dateKey = localDateKey(event.timestamp); 55 if (!dateKey) return; 56 if (!byDate[dateKey]) { 57 byDate[dateKey] = { date: dateKey, pages: [] }; 58 entries.push(byDate[dateKey]); 59 } 60 var todoPage = byDate[dateKey].pages.find(function (page) { 61 return page && page.href === 'todo.html'; 62 }); 63 if (!todoPage) { 64 todoPage = { 65 name: 'TO-DO', 66 href: 'todo.html', 67 icon: 'fa-list-check', 68 groups: [{ type: 'updated', items: [] }] 69 }; 70 byDate[dateKey].pages.push(todoPage); 71 } 72 var activityGroup = todoPage.groups.find(function (group) { 73 return group && group.type === 'updated'; 74 }); 75 if (!activityGroup) { 76 activityGroup = { type: 'updated', items: [] }; 77 todoPage.groups.push(activityGroup); 78 } 79 var text = event.type === 'restored' 80 ? '"' + event.title + '" was restored to ' + (event.destination === 'doing' ? 'Doing' : 'To do') + ' by ' + event.actor + '.' 81 : '"' + event.title + '" was completed by ' + event.actor + '.'; 82 activityGroup.items.push({ text: text }); 83 }); 84 85 entries.sort(function (left, right) { 86 return String(right.date).localeCompare(String(left.date)); 87 }); 88 var newestEvent = todoEvents[0]; 89 return { 90 generatedAt: base.generatedAt, 91 revision: (base.revision || '') + ':todo:' + (newestEvent.timestamp || ''), 92 latestDate: entries[0] ? entries[0].date : base.latestDate, 93 entries: entries 94 }; 95 } 96 97 function localDateKey(timestamp) { 98 if (!timestamp) return ''; 99 var date = new Date(timestamp); 100 if (Number.isNaN(date.getTime())) return ''; 101 return [ 102 date.getFullYear(), 103 String(date.getMonth() + 1).padStart(2, '0'), 104 String(date.getDate()).padStart(2, '0') 105 ].join('-'); 106 } 107 108 function refreshRenderedData() { 109 renderPreview(); 110 syncUnreadState(); 111 if (modal) { 112 var entries = modal.querySelector('[data-changelog-entries]'); 113 if (entries) renderEntries(entries); 114 } 115 } 116 117 function subscribeTodoActivity() { 118 if (typeof firebase === 'undefined' || !firebase.database) return; 119 todoActivityRef = firebase.database().ref('todo/activity').orderByChild('timestamp').limitToLast(40); 120 todoActivityHandler = todoActivityRef.on('value', function (snapshot) { 121 var nextEvents = []; 122 snapshot.forEach(function (child) { 123 var value = child.val(); 124 if (!value || !value.timestamp || !value.title || !value.actor) return; 125 nextEvents.push({ 126 id: child.key, 127 type: value.type, 128 title: String(value.title).substring(0, 140), 129 actor: String(value.actor).substring(0, 20), 130 destination: value.destination === 'doing' ? 'doing' : 'todo', 131 timestamp: Number(value.timestamp) 132 }); 133 }); 134 todoEvents = nextEvents.sort(function (left, right) { 135 return right.timestamp - left.timestamp; 136 }); 137 refreshRenderedData(); 138 }, function (error) { 139 if (global.__PUPWIFE_DEBUG__) console.warn('[SiteChangelog] TO-DO activity unavailable:', error); 140 }); 141 } 142 143 function formatDate(isoDate) { 144 if (!isoDate) return ''; 145 var parts = isoDate.split('-').map(Number); 146 if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) return isoDate; 147 try { 148 return new Intl.DateTimeFormat(undefined, { 149 month: 'long', 150 day: 'numeric', 151 year: 'numeric' 152 }).format(new Date(parts[0], parts[1] - 1, parts[2])); 153 } catch (error) { 154 return isoDate; 155 } 156 } 157 158 function makeIcon(name) { 159 var icon = document.createElement('i'); 160 icon.className = 'fa-solid ' + name; 161 icon.setAttribute('aria-hidden', 'true'); 162 return icon; 163 } 164 165 function hasUnreadUpdates(opts) { 166 var revision = getData(opts).revision; 167 if (!revision) return false;
168 try { 169 return localStorage.getItem(STORAGE_KEY) !== revision; 170 } catch (error) { 171 return false; 172 } 173 } 174 175 function markSeen(opts) { 176 var revision = getData(opts).revision; 177 if (revision) { 178 try { 179 localStorage.setItem(STORAGE_KEY, revision); 180 } catch (error) { 181 /* The Changelog still works when storage is unavailable. */ 182 } 183 } 184 syncUnreadState(); 185 } 186 187 function getCollapsedDays() { 188 try { 189 var saved = JSON.parse(localStorage.getItem(COLLAPSED_DAYS_STORAGE_KEY) || '{}'); 190 return saved && typeof saved === 'object' && !Array.isArray(saved) ? saved : {}; 191 } catch (error) { 192 return {}; 193 } 194 } 195 196 function saveCollapsedDays(days) { 197 try { 198 localStorage.setItem(COLLAPSED_DAYS_STORAGE_KEY, JSON.stringify(days)); 199 } catch (error) { 200 /* Collapsing still works for this visit when storage is unavailable. */ 201 } 202 } 203 204 function setDayCollapsed(date, collapsed) { 205 var days = getCollapsedDays(); 206 if (collapsed) days[date] = true; 207 else delete days[date]; 208 saveCollapsedDays(days); 209 } 210 211 function getCollapsedSections() { 212 try { 213 var saved = JSON.parse(localStorage.getItem(COLLAPSED_SECTIONS_STORAGE_KEY) || '{}'); 214 return saved && typeof saved === 'object' && !Array.isArray(saved) ? saved : {}; 215 } catch (error) { 216 return {}; 217 } 218 } 219 220 function saveCollapsedSections(sections) { 221 try { 222 localStorage.setItem(COLLAPSED_SECTIONS_STORAGE_KEY, JSON.stringify(sections)); 223 } catch (error) { 224 /* Collapsing still works for this visit when storage is unavailable. */ 225 } 226 } 227 228 function setSectionCollapsed(key, collapsed) { 229 var sections = getCollapsedSections(); 230 if (collapsed) sections[key] = true; 231 else delete sections[key]; 232 saveCollapsedSections(sections); 233 } 234 235 function syncUnreadState(targetButton, opts) { 236 var btn = targetButton || openButton; 237 if (!btn) return; 238 var unread = hasUnreadUpdates(opts); 239 var badge = btn.querySelector('[data-changelog-badge]'); 240 if (badge) badge.hidden = !unread; 241 btn.classList.toggle('db-changelog-open--unread', unread); 242 btn.classList.toggle('citrus-widget-changelog__panel--unread', unread); 243 } 244 245 function renderPreview(targetButton, opts) { 246 var btn = targetButton || openButton; 247 if (!btn) return; 248 var data = getData(opts); 249 var latest = data.entries && data.entries[0]; 250 var dateEl = btn.querySelector('[data-changelog-preview-date]'); 251 var summaryEl = btn.querySelector('[data-changelog-preview-summary]'); 252 var chipsEl = btn.querySelector('[data-changelog-preview-pages]'); 253 var chipClass = btn.classList.contains('citrus-widget-changelog__panel') 254 ? 'citrus-widget-changelog__chip' 255 : 'db-changelog-chip'; 256 var chipMoreClass = chipClass + (chipClass.indexOf('citrus-') === 0 ? ' citrus-widget-changelog__chip--more' : ' db-changelog-chip--more'); 257 258 if (!latest) { 259 if (dateEl) dateEl.textContent = 'No entries yet'; 260 if (summaryEl) summaryEl.textContent = 'Check back after the next bit of tinkering.'; 261 return; 262 } 263 264 var pages = latest.pages || []; 265 if (dateEl) dateEl.textContent = formatDate(latest.date); 266 if (summaryEl) { 267 summaryEl.textContent = pages.length + (pages.length === 1 ? ' page changed' : ' pages changed'); 268 } 269 if (chipsEl) { 270 chipsEl.replaceChildren(); 271 if (!pages.length) { 272 chipsEl.hidden = true; 273 } else { 274 chipsEl.hidden = false; 275 } 276 pages.slice(0, 3).forEach(function (page) { 277 var chip = document.createElement('span'); 278 chip.className = chipClass; 279 chip.textContent = page.name; 280 chipsEl.appendChild(chip); 281 }); 282 if (pages.length > 3) { 283 var more = document.createElement('span'); 284 more.className = chipMoreClass; 285 more.textContent = '+' + (pages.length - 3); 286 chipsEl.appendChild(more); 287 } 288 } 289 } 290 291 function createItem(item) { 292 var li = document.createElement('li'); 293 li.className = 'site-changelog-item'; 294 if (item.href) { 295 var link = document.createElement('a'); 296 link.className = 'site-changelog-item__link'; 297 link.href = item.href; 298 link.textContent = item.text; 299 link.appendChild(makeIcon('fa-arrow-right')); 300 li.appendChild(link); 301 } else { 302 li.textContent = item.text; 303 } 304 return li; 305 } 306 307 function createPage(page, date, index, collapsedSections) { 308 var section = document.createElement('section'); 309 section.className = 'site-changelog-page'; 310 311 var sectionKey = date + ':' + (page.href || page.name); 312 var contentId = 'site-changelog-section-' + date + '-' + index; 313 var collapsed = collapsedSections[sectionKey] === true; 314 var header = document.createElement('div'); 315 header.className = 'site-changelog-page__header'; 316 317 var title = document.createElement('h3'); 318 title.className = 'site-changelog-page__title'; 319 var titleContent = page.href ? document.createElement('a') : document.createElement('span'); 320 titleContent.className = 'site-changelog-page__link'; 321 if (page.href) titleContent.href = page.href; 322 titleContent.appendChild(makeIcon(page.icon || 'fa-file-lines')); 323 var label = document.createElement('span'); 324 label.textContent = page.name; 325 titleContent.appendChild(label); 326 if (page.href) titleContent.appendChild(makeIcon('fa-arrow-up-right-from-square')); 327 title.appendChild(titleContent); 328 header.appendChild(title); 329 330 var toggle = document.createElement('button'); 331 toggle.type = 'button'; 332 toggle.className = 'site-changelog-page__toggle'; 333 toggle.dataset.changelogSectionToggle = sectionKey; 334 toggle.dataset.changelogSectionLabel = page.name; 335 toggle.setAttribute('aria-expanded', String(!collapsed)); 336 toggle.setAttribute('aria-controls', contentId); 337 toggle.setAttribute('aria-label', (collapsed ? 'Expand ' : 'Collapse ') + page.name + ' section'); 338 var chevron = makeIcon('fa-chevron-down'); 339 chevron.classList.add('site-changelog-page__chevron'); 340 toggle.appendChild(chevron); 341 header.appendChild(toggle); 342 section.appendChild(header); 343 344 var content = document.createElement('div'); 345 content.className = 'site-changelog-page__content'; 346 content.id = contentId; 347 content.hidden = collapsed; 348
349 (page.groups || []).forEach(function (group) { 350 var meta = CATEGORY_META[group.type] || { label: group.type, icon: 'fa-circle' }; 351 var groupEl = document.createElement('div'); 352 groupEl.className = 'site-changelog-group site-changelog-group--' + group.type; 353 354 var heading = document.createElement('h4'); 355 heading.className = 'site-changelog-group__title'; 356 heading.appendChild(makeIcon(meta.icon)); 357 var headingLabel = document.createElement('span'); 358 headingLabel.textContent = meta.label; 359 heading.appendChild(headingLabel); 360 groupEl.appendChild(heading); 361 362 var list = document.createElement('ul'); 363 list.className = 'site-changelog-list'; 364 (group.items || []).forEach(function (item) { 365 list.appendChild(createItem(item)); 366 }); 367 groupEl.appendChild(list); 368 content.appendChild(groupEl); 369 }); 370 section.appendChild(content); 371 372 return section; 373 } 374 375 function renderEntries(container, opts) { 376 opts = opts || {}; 377 var entries = getData(opts).entries || []; 378 var collapsedDays = getCollapsedDays(); 379 var collapsedSections = getCollapsedSections(); 380 var latestDate = entries[0] ? entries[0].date : ''; 381 if (latestDate && hasUnreadUpdates(opts) && collapsedDays[latestDate]) { 382 delete collapsedDays[latestDate]; 383 saveCollapsedDays(collapsedDays); 384 } 385 if (latestDate && hasUnreadUpdates(opts)) { 386 Object.keys(collapsedSections).forEach(function (key) { 387 if (key.indexOf(latestDate + ':') === 0) delete collapsedSections[key]; 388 }); 389 saveCollapsedSections(collapsedSections); 390 } 391 container.replaceChildren(); 392 if (!entries.length) { 393 var empty = document.createElement('p'); 394 empty.className = 'site-changelog-empty'; 395 empty.textContent = 'No changelog entries yet. Check back soon!'; 396 container.appendChild(empty); 397 return; 398 } 399 400 entries.forEach(function (entry) { 401 var article = document.createElement('article'); 402 article.className = 'site-changelog-day'; 403 404 var contentId = 'site-changelog-day-' + entry.date; 405 var collapsed = collapsedDays[entry.date] === true; 406 var toggle = document.createElement('button'); 407 toggle.type = 'button'; 408 toggle.className = 'site-changelog-day__toggle'; 409 toggle.dataset.changelogDayToggle = entry.date; 410 toggle.setAttribute('aria-expanded', String(!collapsed)); 411 toggle.setAttribute('aria-controls', contentId); 412 413 var date = document.createElement('time'); 414 date.className = 'site-changelog-day__date'; 415 date.dateTime = entry.date; 416 date.appendChild(makeIcon('fa-calendar-day')); 417 var dateLabel = document.createElement('span'); 418 dateLabel.textContent = formatDate(entry.date); 419 date.appendChild(dateLabel); 420 toggle.appendChild(date); 421 var chevron = makeIcon('fa-chevron-down'); 422 chevron.classList.add('site-changelog-day__chevron'); 423 toggle.appendChild(chevron); 424 article.appendChild(toggle); 425 426 var pages = document.createElement('div'); 427 pages.className = 'site-changelog-day__pages'; 428 pages.id = contentId; 429 pages.hidden = collapsed; 430 (entry.pages || []).forEach(function (page, index) { 431 pages.appendChild(createPage(page, entry.date, index, collapsedSections)); 432 }); 433 article.appendChild(pages); 434 container.appendChild(article); 435 }); 436 } 437 438 function createModal() { 439 var root = document.createElement('div'); 440 root.className = 'site-changelog-modal'; 441 root.hidden = true; 442 root.innerHTML = 443 '<div class="site-changelog-modal__backdrop" data-changelog-close></div>' + 444 '<section class="site-changelog-modal__panel" role="dialog" aria-modal="true" aria-labelledby="site-changelog-title" aria-describedby="site-changelog-intro">' + 445 '<header class="site-changelog-modal__header">' + 446 '<div>' + 447 '<p class="site-changelog-modal__eyebrow"><i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i> what changed?</p>' + 448 '<h2 id="site-changelog-title">Changelog</h2>' + 449 '<p id="site-changelog-intro">Little notes about what I have been working on around the site.</p>' + 450 '</div>' + 451 '<button type="button" class="site-changelog-close" data-changelog-close aria-label="Close Changelog">' + 452 '<i class="fa-solid fa-xmark" aria-hidden="true"></i>' + 453 '</button>' + 454 '</header>' + 455 '<div class="site-changelog-modal__body" data-changelog-entries></div>' + 456 '</section>'; 457 document.body.appendChild(root); 458 renderEntries(root.querySelector('[data-changelog-entries]')); 459 return root; 460 } 461 462 function getFocusableElements() { 463 if (!modal) return []; 464 return Array.prototype.slice.call( 465 modal.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not
465([tabindex="-1"])') 466 ).filter(function (element) { 467 return !element.hidden && element.getClientRects().length > 0; 468 }); 469 } 470 471 function closeModal() { 472 if (!modal || modal.hidden) return; 473 modal.hidden = true; 474 document.body.classList.remove('site-changelog-modal-open'); 475 if (lastFocused && document.contains(lastFocused)) lastFocused.focus(); 476 lastFocused = null; 477 } 478 479 function handleModalKeydown(event) { 480 if (!modal || modal.hidden) return; 481 if (event.key === 'Escape') { 482 event.preventDefault(); 483 closeModal(); 484 return; 485 } 486 if (event.key !== 'Tab') return; 487 var focusable = getFocusableElements(); 488 if (!focusable.length) return; 489 var first = focusable[0]; 490 var last = focusable[focusable.length - 1]; 491 if (event.shiftKey && document.activeElement === first) { 492 event.preventDefault(); 493 last.focus(); 494 } else if (!event.shiftKey && document.activeElement === last) { 495 event.preventDefault(); 496 first.focus(); 497 } 498 } 499 500 function handleEntriesClick(event) { 501 var target = event.target instanceof Element ? event.target : event.target.parentElement; 502 if (!target) return false; 503 var dayToggle = target.closest('[data-changelog-day-toggle]'); 504 if (dayToggle) { 505 var dayContent = document.getElementById(dayToggle.getAttribute('aria-controls')); 506 var willCollapse = dayToggle.getAttribute('aria-expanded') === 'true'; 507 dayToggle.setAttribute('aria-expanded', String(!willCollapse)); 508 if (dayContent) dayContent.hidden = willCollapse; 509 setDayCollapsed(dayToggle.dataset.changelogDayToggle, willCollapse); 510 return true; 511 } 512 var sectionToggle = target.closest('[data-changelog-section-toggle]'); 513 if (sectionToggle) { 514 var sectionContent = document.getElementById(sectionToggle.getAttribute('aria-controls')); 515 var willCollapseSection = sectionToggle.getAttribute('aria-expanded') === 'true'; 516 sectionToggle.setAttribute('aria-expanded', String(!willCollapseSection)); 517 sectionToggle.setAttribute( 518 'aria-label', 519 (willCollapseSection ? 'Expand ' : 'Collapse ') + 520 sectionToggle.dataset.changelogSectionLabel + 521 ' section' 522 ); 523 if (sectionContent) sectionContent.hidden = willCollapseSection; 524 setSectionCollapsed(sectionToggle.dataset.changelogSectionToggle, willCollapseSection); 525 return true; 526 } 527 return false; 528 } 529 530 function handleModalClick(event) { 531 if (handleEntriesClick(event)) return; 532 var target = event.target instanceof Element ? event.target : event.target.parentElement; 533 if (!target) return; 534 if (target.closest('[data-changelog-close]')) { 535 closeModal(); 536 return; 537 } 538 if (target.closest('a[href]')) closeModal(); 539 } 540 541 var DATA_OPTS_STATIC = { includeTodoActivity: false }; 542 /** @type {WeakMap<Element, { onClick: function(Event): void }>} */ 543 var scopeControllers = new WeakMap(); 544 545 function bindEntriesInteractions(scopeEl) { 546 if (!scopeEl) return function () {}; 547 function onClick(ev) { 548 handleEntriesClick(ev); 549 } 550 scopeEl.addEventListener('click', onClick); 551 return function () { 552 scopeEl.removeEventListener('click', onClick); 553 }; 554 } 555 556 function mountInScope(scopeEl, opts) { 557 opts = opts || {}; 558 if (!scopeEl) return function () {}; 559 destroyInScope(scopeEl); 560 var entriesHost = scopeEl.querySelector('[data-changelog-entries]'); 561 if (!entriesHost) return function () {}; 562 var dataOpts = opts.includeTodoActivity === true ? undefined : DATA_OPTS_STATIC; 563 renderEntries(entriesHost, dataOpts); 564 var unbind = bindEntriesInteractions(scopeEl); 565 if (opts.markSeen !== false) markSeen(dataOpts); 566 scopeControllers.set(scopeEl, { unbind: unbind }); 567 return unbind; 568 } 569 570 function destroyInScope(scopeEl) { 571 if (!scopeEl) return; 572 var ctrl = scopeControllers.get(scopeEl); 573 if (ctrl && typeof ctrl.unbind === 'function') ctrl.unbind(); 574 scopeControllers.delete(scopeEl); 575 } 576 577 function openModal() { 578 if (!modal) modal = createModal(); 579 lastFocused = document.activeElement; 580 modal.hidden = false; 581 document.body.classList.add('site-changelog-modal-open'); 582 markSeen(); 583 var closeButton = modal.querySelector('.site-changelog-close'); 584 if (closeButton) closeButton.focus(); 585 } 586 587 function destroy() {
588 if (openButton) openButton.removeEventListener('click', openModal); 589 document.removeEventListener('keydown', handleModalKeydown); 590 if (todoActivityRef && todoActivityHandler) { 591 todoActivityRef.off('value', todoActivityHandler); 592 } 593 if (modal) { 594 modal.removeEventListener('click', handleModalClick); 595 modal.remove(); 596 } 597 document.body.classList.remove('site-changelog-modal-open'); 598 openButton = null; 599 modal = null; 600 lastFocused = null; 601 todoActivityRef = null; 602 todoActivityHandler = null; 603 todoEvents = []; 604 } 605 606 function init() { 607 destroy(); 608 openButton = document.querySelector('[data-changelog-open]'); 609 if (!openButton) return; 610 renderPreview(); 611 syncUnreadState(); 612 openButton.addEventListener('click', openModal); 613 document.addEventListener('keydown', handleModalKeydown); 614 modal = createModal(); 615 modal.addEventListener('click', handleModalClick); 616 subscribeTodoActivity(); 617 } 618 619 global.SiteChangelog = { 620 init: init, 621 destroy: destroy, 622 open: openModal, 623 close: closeModal, 624 getData: getData, 625 formatDate: formatDate, 626 hasUnread: hasUnreadUpdates, 627 markSeen: markSeen, 628 renderPreview: renderPreview, 629 renderEntries: renderEntries, 630 bindEntriesInteractions: bindEntriesInteractions, 631 mountInScope: mountInScope, 632 destroyInScope: destroyInScope, 633 syncUnread: syncUnreadState, 634 DATA_OPTS_STATIC: DATA_OPTS_STATIC 635 }; 636 637 if (document.readyState === 'loading') { 638 document.addEventListener('DOMContentLoaded', init, { once: true }); 639 } else { 640 init(); 641 } 642})(typeof window !== 'undefined' ? window : this);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.