123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653 |
- import { setupPopovers } from './popover.js';
- import { setupMasonries } from './masonry.js';
- import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
- async function fetchPageContent(pageData) {
- // TODO: handle non 200 status codes/time outs
- // TODO: add retries
- const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/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;
- let lastQuery = "";
- 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 && currentBang == null) {
- 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;
- }
- lastQuery = query;
- inputElement.value = "";
- return;
- }
- if (event.key == "ArrowUp" && lastQuery.length > 0) {
- inputElement.value = lastQuery;
- return;
- }
- };
- const changeCurrentBang = (bang) => {
- currentBang = bang;
- bangElement.textContent = bang != null ? bang.dataset.title : "";
- }
- const handleInput = (event) => {
- const value = event.target.value.trim();
- if (value in bangsMap) {
- changeCurrentBang(bangsMap[value]);
- return;
- }
- 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 setupGroups() {
- const groups = document.getElementsByClassName("widget-type-group");
- if (groups.length == 0) {
- return;
- }
- for (let g = 0; g < groups.length; g++) {
- const group = groups[g];
- const titles = group.getElementsByClassName("widget-header")[0].children;
- const tabs = group.getElementsByClassName("widget-group-contents")[0].children;
- let current = 0;
- for (let t = 0; t < titles.length; t++) {
- const title = titles[t];
- if (title.dataset.titleUrl !== undefined) {
- title.addEventListener("mousedown", (event) => {
- if (event.button != 1) {
- return;
- }
- openURLInNewTab(title.dataset.titleUrl);
- event.preventDefault();
- });
- }
- title.addEventListener("click", () => {
- if (t == current) {
- if (title.dataset.titleUrl !== undefined) {
- openURLInNewTab(title.dataset.titleUrl);
- }
- return;
- }
- for (let i = 0; i < titles.length; i++) {
- titles[i].classList.remove("widget-group-title-current");
- tabs[i].classList.remove("widget-group-content-current");
- }
- if (current < t) {
- tabs[t].dataset.direction = "right";
- } else {
- tabs[t].dataset.direction = "left";
- }
- current = t;
- title.classList.add("widget-group-title-current");
- tabs[t].classList.add("widget-group-content-current");
- });
- }
- }
- }
- 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;
- 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");
- }
- }
- };
- const observer = new ResizeObserver(() => {
- if (!isElementVisible(gridElement)) {
- return;
- }
- const newCardsPerRow = getCardsPerRow();
- if (cardsPerRow == newCardsPerRow) {
- return;
- }
- cardsPerRow = newCardsPerRow;
- resolveCollapsibleItems();
- });
- afterContentReady(() => observer.observe(gridElement));
- }
- }
- 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 diffInMinutes = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60);
- return { time: timeInZone, diffInMinutes: diffInMinutes };
- }
- function zoneDiffText(diffInMinutes) {
- if (diffInMinutes == 0) {
- return "";
- }
- const sign = diffInMinutes < 0 ? "-" : "+";
- const signText = diffInMinutes < 0 ? "behind" : "ahead";
- diffInMinutes = Math.abs(diffInMinutes);
- const hours = Math.floor(diffInMinutes / 60);
- const minutes = diffInMinutes % 60;
- const hourSuffix = hours == 1 ? "" : "s";
- if (minutes == 0) {
- return { text: `${sign}${hours}h`, title: `${hours} hour${hourSuffix} ${signText}` };
- }
- if (hours == 0) {
- return { text: `${sign}${minutes}m`, title: `${minutes} minutes ${signText}` };
- }
- return { text: `${sign}${hours}h~`, title: `${hours} hour${hourSuffix} and ${minutes} minutes ${signText}` };
- }
- 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, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
- setZoneTime(time);
- const { text, title } = zoneDiffText(diffInMinutes);
- diffElement.textContent = text;
- diffElement.title = title;
- });
- }
- }
- 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);
- pageContentElement.innerHTML = pageContent;
- try {
- setupPopovers();
- setupClocks()
- setupCarousels();
- setupSearchBoxes();
- setupCollapsibleLists();
- setupCollapsibleGrids();
- setupGroups();
- setupMasonries();
- 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();
|