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.innerText = 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); } async function setupPage() { const pageElement = document.getElementById("page"); const pageContentElement = document.getElementById("page-content"); const pageContent = await fetchPageContent(pageData.slug); pageContentElement.innerHTML = pageContent; try { 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(); }