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); window.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 setupSearchBoxes() { const searchWidgets = document.getElementsByClassName("search"); if (searchWidgets.length == 0) { return; } for (let i = 0; i < searchWidgets.length; i++) { const widget = searchWidgets[i]; const defaultSearchUrl = widget.dataset.defaultSearchUrl; const newTab = widget.dataset.newTab === "true"; const inputElement = widget.getElementsByClassName("search-input")[0]; const bangElement = widget.getElementsByClassName("search-bang")[0]; const bangs = widget.querySelectorAll(".search-bangs > input"); const bangsMap = {}; const kbdElement = widget.getElementsByTagName("kbd")[0]; let currentBang = null; for (let j = 0; j < bangs.length; j++) { const bang = bangs[j]; bangsMap[bang.dataset.shortcut] = bang; } const handleKeyDown = (event) => { if (event.key == "Escape") { inputElement.blur(); return; } if (event.key == "Enter") { const input = inputElement.value.trim(); let query; let searchUrlTemplate; if (currentBang != null) { query = input.slice(currentBang.dataset.shortcut.length + 1); searchUrlTemplate = currentBang.dataset.url; } else { query = input; searchUrlTemplate = defaultSearchUrl; } if (query.length == 0) { return; } const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query)); if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) { window.open(url, '_blank').focus(); } else { window.location.href = url; } return; } }; const changeCurrentBang = (bang) => { currentBang = bang; bangElement.textContent = bang != null ? bang.dataset.title : ""; } const handleInput = (event) => { const value = event.target.value.trimStart(); const words = value.split(" "); if (words.length >= 2 && words[0] in bangsMap) { changeCurrentBang(bangsMap[words[0]]); return; } changeCurrentBang(null); }; inputElement.addEventListener("focus", () => { document.addEventListener("keydown", handleKeyDown); document.addEventListener("input", handleInput); }); inputElement.addEventListener("blur", () => { document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("input", handleInput); }); document.addEventListener("keydown", (event) => { if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; if (event.key != "s") return; inputElement.focus(); event.preventDefault(); }); kbdElement.addEventListener("mousedown", () => { requestAnimationFrame(() => inputElement.focus()); }); } } 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"; } } } 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(); }); 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(); setupSearchBoxes(); 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); } } setupPage();