function throttledDebounce(callback, maxDebounceTimes, debounceDelay) { let debounceTimeout; let timesDebounced = 0; return function () { if (timesDebounced == maxDebounceTimes) { clearTimeout(debounceTimeout); timesDebounced = 0; callback(); return; } clearTimeout(debounceTimeout); timesDebounced++; debounceTimeout = setTimeout(() => { timesDebounced = 0; callback(); }, debounceDelay); }; }; async function fetchPageContent(pageSlug) { // TODO: handle non 200 status codes/time outs // TODO: add retries const response = await fetch(`/api/pages/${pageSlug}/content/`); const content = await response.text(); return content; } function setupCarousels() { const carouselElements = document.getElementsByClassName("carousel-container"); if (carouselElements.length == 0) { return; } for (let i = 0; i < carouselElements.length; i++) { const carousel = carouselElements[i]; carousel.classList.add("show-right-cutoff"); const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0]; const determineSideCutoffs = () => { if (itemsContainer.scrollLeft != 0) { carousel.classList.add("show-left-cutoff"); } else { carousel.classList.remove("show-left-cutoff"); } if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) { carousel.classList.add("show-right-cutoff"); } else { carousel.classList.remove("show-right-cutoff"); } } const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100); itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited); document.addEventListener("resize", determineSideCutoffsRateLimited); afterContentReady(determineSideCutoffs); } } const minuteInSeconds = 60; const hourInSeconds = minuteInSeconds * 60; const dayInSeconds = hourInSeconds * 24; const monthInSeconds = dayInSeconds * 30; const yearInSeconds = monthInSeconds * 12; function relativeTimeSince(timestamp) { const delta = Math.round((Date.now() / 1000) - timestamp); if (delta < minuteInSeconds) { return "1m"; } if (delta < hourInSeconds) { return Math.floor(delta / minuteInSeconds) + "m"; } if (delta < dayInSeconds) { return Math.floor(delta / hourInSeconds) + "h"; } if (delta < monthInSeconds) { return Math.floor(delta / dayInSeconds) + "d"; } if (delta < yearInSeconds) { return Math.floor(delta / monthInSeconds) + "mo"; } return Math.floor(delta / yearInSeconds) + "y"; } function updateRelativeTimeForElements(elements) { for (let i = 0; i < elements.length; i++) { const element = elements[i]; const timestamp = element.dataset.dynamicRelativeTime; if (timestamp === undefined) continue element.textContent = relativeTimeSince(timestamp); } } function setupDynamicRelativeTime() { const elements = document.querySelectorAll("[data-dynamic-relative-time]"); const updateInterval = 60 * 1000; let lastUpdateTime = Date.now(); updateRelativeTimeForElements(elements); const updateElementsAndTimestamp = () => { updateRelativeTimeForElements(elements); lastUpdateTime = Date.now(); }; const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval); if (document.hidden === undefined) { scheduleRepeatingUpdate(); return; } let timeout = scheduleRepeatingUpdate(); document.addEventListener("visibilitychange", () => { if (document.hidden) { clearTimeout(timeout); return; } const delta = Date.now() - lastUpdateTime; if (delta >= updateInterval) { updateElementsAndTimestamp(); timeout = scheduleRepeatingUpdate(); return; } timeout = setTimeout(() => { updateElementsAndTimestamp(); timeout = scheduleRepeatingUpdate(); }, updateInterval - delta); }); } function setupLazyImages() { const images = document.querySelectorAll("img[loading=lazy]"); if (images.length == 0) { return; } function imageFinishedTransition(image) { image.classList.add("finished-transition"); } afterContentReady(() => { setTimeout(() => { for (let i = 0; i < images.length; i++) { const image = images[i]; if (image.complete) { image.classList.add("cached"); setTimeout(() => imageFinishedTransition(image), 1); } else { // TODO: also handle error event image.addEventListener("load", () => { image.classList.add("loaded"); setTimeout(() => imageFinishedTransition(image), 400); }); } } }, 1); }); } function attachExpandToggleButton(collapsibleContainer) { const showMoreText = "Show more"; const showLessText = "Show less"; let expanded = false; const button = document.createElement("button"); const icon = document.createElement("span"); icon.classList.add("expand-toggle-button-icon"); const textNode = document.createTextNode(showMoreText); button.classList.add("expand-toggle-button"); button.append(textNode, icon); button.addEventListener("click", () => { expanded = !expanded; if (expanded) { collapsibleContainer.classList.add("container-expanded"); button.classList.add("container-expanded"); textNode.nodeValue = showLessText; return; } const topBefore = button.getClientRects()[0].top; collapsibleContainer.classList.remove("container-expanded"); button.classList.remove("container-expanded"); textNode.nodeValue = showMoreText; const topAfter = button.getClientRects()[0].top; if (topAfter > 0) return; window.scrollBy({ top: topAfter - topBefore, behavior: "instant" }); }); collapsibleContainer.after(button); return button; }; function setupCollapsibleLists() { const collapsibleLists = document.querySelectorAll(".list.collapsible-container"); if (collapsibleLists.length == 0) { return; } for (let i = 0; i < collapsibleLists.length; i++) { const list = collapsibleLists[i]; if (list.dataset.collapseAfter === undefined) { continue; } const collapseAfter = parseInt(list.dataset.collapseAfter); if (collapseAfter == -1) { continue; } if (list.children.length <= collapseAfter) { continue; } attachExpandToggleButton(list); for (let c = collapseAfter; c < list.children.length; c++) { const child = list.children[c]; child.classList.add("collapsible-item"); child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms"; } list.classList.add("ready"); } } function setupCollapsibleGrids() { const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container"); if (collapsibleGridElements.length == 0) { return; } for (let i = 0; i < collapsibleGridElements.length; i++) { const gridElement = collapsibleGridElements[i]; if (gridElement.dataset.collapseAfterRows === undefined) { continue; } const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows); if (collapseAfterRows == -1) { continue; } const getCardsPerRow = () => { return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row')); }; const button = attachExpandToggleButton(gridElement); let cardsPerRow = 2; const resolveCollapsibleItems = () => { const hideItemsAfterIndex = cardsPerRow * collapseAfterRows; if (hideItemsAfterIndex >= gridElement.children.length) { button.style.display = "none"; } else { button.style.removeProperty("display"); } let row = 0; for (let i = 0; i < gridElement.children.length; i++) { const child = gridElement.children[i]; if (i >= hideItemsAfterIndex) { child.classList.add("collapsible-item"); child.style.animationDelay = (row * 40).toString() + "ms"; if (i % cardsPerRow + 1 == cardsPerRow) { row++; } } else { child.classList.remove("collapsible-item"); child.style.removeProperty("animation-delay"); } } }; afterContentReady(() => { cardsPerRow = getCardsPerRow(); resolveCollapsibleItems(); gridElement.classList.add("ready"); }); window.addEventListener("resize", () => { const newCardsPerRow = getCardsPerRow(); if (cardsPerRow == newCardsPerRow) { return; } cardsPerRow = newCardsPerRow; resolveCollapsibleItems(); }); } } const contentReadyCallbacks = []; function afterContentReady(callback) { contentReadyCallbacks.push(callback); } const weekDayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; function makeSettableTimeElement(element, hourFormat) { const fragment = document.createDocumentFragment(); const hour = document.createElement('span'); const minute = document.createElement('span'); const amPm = document.createElement('span'); fragment.append(hour, document.createTextNode(':'), minute); if (hourFormat == '12h') { fragment.append(document.createTextNode(' '), amPm); } element.append(fragment); return (date) => { const hours = date.getHours(); if (hourFormat == '12h') { amPm.textContent = hours < 12 ? 'AM' : 'PM'; hour.textContent = hours % 12 || 12; } else { hour.textContent = hours < 10 ? '0' + hours : hours; } const minutes = date.getMinutes(); minute.textContent = minutes < 10 ? '0' + minutes : minutes; }; }; function timeInZone(now, zone) { let timeInZone; try { timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone })); } catch (e) { // TODO: indicate to the user that this is an invalid timezone console.error(e); timeInZone = now } const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60); return { time: timeInZone, diffInHours: diffInHours }; } function setupClocks() { const clocks = document.getElementsByClassName('clock'); if (clocks.length == 0) { return; } const updateCallbacks = []; for (var i = 0; i < clocks.length; i++) { const clock = clocks[i]; const hourFormat = clock.dataset.hourFormat; const localTimeContainer = clock.querySelector('[data-local-time]'); const localDateElement = localTimeContainer.querySelector('[data-date]'); const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]'); const localYearElement = localTimeContainer.querySelector('[data-year]'); const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]'); const setLocalTime = makeSettableTimeElement( localTimeContainer.querySelector('[data-time]'), hourFormat ); updateCallbacks.push((now) => { setLocalTime(now); localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()]; localWeekdayElement.textContent = weekDayNames[now.getDay()]; localYearElement.textContent = now.getFullYear(); }); for (var z = 0; z < timeZoneContainers.length; z++) { const timeZoneContainer = timeZoneContainers[z]; const diffElement = timeZoneContainer.querySelector('[data-time-diff]'); const setZoneTime = makeSettableTimeElement( timeZoneContainer.querySelector('[data-time]'), hourFormat ); updateCallbacks.push((now) => { const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone); setZoneTime(time); diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h'; }); } } const updateClocks = () => { const now = new Date(); for (var i = 0; i < updateCallbacks.length; i++) updateCallbacks[i](now); setTimeout(updateClocks, (60 - now.getSeconds()) * 1000); }; updateClocks(); } async function setupPage() { const pageElement = document.getElementById("page"); const pageContentElement = document.getElementById("page-content"); const pageContent = await fetchPageContent(pageData.slug); pageContentElement.innerHTML = pageContent; try { setupClocks() setupCarousels(); setupCollapsibleLists(); setupCollapsibleGrids(); setupDynamicRelativeTime(); setupLazyImages(); } finally { pageElement.classList.add("content-ready"); for (let i = 0; i < contentReadyCallbacks.length; i++) { contentReadyCallbacks[i](); } setTimeout(() => { document.body.classList.add("page-columns-transitioned"); }, 300); } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", setupPage); } else { setupPage(); }