main.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
  2. let debounceTimeout;
  3. let timesDebounced = 0;
  4. return function () {
  5. if (timesDebounced == maxDebounceTimes) {
  6. clearTimeout(debounceTimeout);
  7. timesDebounced = 0;
  8. callback();
  9. return;
  10. }
  11. clearTimeout(debounceTimeout);
  12. timesDebounced++;
  13. debounceTimeout = setTimeout(() => {
  14. timesDebounced = 0;
  15. callback();
  16. }, debounceDelay);
  17. };
  18. };
  19. async function fetchPageContent(pageData) {
  20. // TODO: handle non 200 status codes/time outs
  21. // TODO: add retries
  22. const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/`);
  23. const content = await response.text();
  24. return content;
  25. }
  26. function setupCarousels() {
  27. const carouselElements = document.getElementsByClassName("carousel-container");
  28. if (carouselElements.length == 0) {
  29. return;
  30. }
  31. for (let i = 0; i < carouselElements.length; i++) {
  32. const carousel = carouselElements[i];
  33. carousel.classList.add("show-right-cutoff");
  34. const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
  35. const determineSideCutoffs = () => {
  36. if (itemsContainer.scrollLeft != 0) {
  37. carousel.classList.add("show-left-cutoff");
  38. } else {
  39. carousel.classList.remove("show-left-cutoff");
  40. }
  41. if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) {
  42. carousel.classList.add("show-right-cutoff");
  43. } else {
  44. carousel.classList.remove("show-right-cutoff");
  45. }
  46. }
  47. const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
  48. itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
  49. window.addEventListener("resize", determineSideCutoffsRateLimited);
  50. afterContentReady(determineSideCutoffs);
  51. }
  52. }
  53. const minuteInSeconds = 60;
  54. const hourInSeconds = minuteInSeconds * 60;
  55. const dayInSeconds = hourInSeconds * 24;
  56. const monthInSeconds = dayInSeconds * 30;
  57. const yearInSeconds = monthInSeconds * 12;
  58. function relativeTimeSince(timestamp) {
  59. const delta = Math.round((Date.now() / 1000) - timestamp);
  60. if (delta < minuteInSeconds) {
  61. return "1m";
  62. }
  63. if (delta < hourInSeconds) {
  64. return Math.floor(delta / minuteInSeconds) + "m";
  65. }
  66. if (delta < dayInSeconds) {
  67. return Math.floor(delta / hourInSeconds) + "h";
  68. }
  69. if (delta < monthInSeconds) {
  70. return Math.floor(delta / dayInSeconds) + "d";
  71. }
  72. if (delta < yearInSeconds) {
  73. return Math.floor(delta / monthInSeconds) + "mo";
  74. }
  75. return Math.floor(delta / yearInSeconds) + "y";
  76. }
  77. function updateRelativeTimeForElements(elements)
  78. {
  79. for (let i = 0; i < elements.length; i++)
  80. {
  81. const element = elements[i];
  82. const timestamp = element.dataset.dynamicRelativeTime;
  83. if (timestamp === undefined)
  84. continue
  85. element.textContent = relativeTimeSince(timestamp);
  86. }
  87. }
  88. function setupSearchBoxes() {
  89. const searchWidgets = document.getElementsByClassName("search");
  90. if (searchWidgets.length == 0) {
  91. return;
  92. }
  93. for (let i = 0; i < searchWidgets.length; i++) {
  94. const widget = searchWidgets[i];
  95. const defaultSearchUrl = widget.dataset.defaultSearchUrl;
  96. const newTab = widget.dataset.newTab === "true";
  97. const inputElement = widget.getElementsByClassName("search-input")[0];
  98. const bangElement = widget.getElementsByClassName("search-bang")[0];
  99. const bangs = widget.querySelectorAll(".search-bangs > input");
  100. const bangsMap = {};
  101. const kbdElement = widget.getElementsByTagName("kbd")[0];
  102. let currentBang = null;
  103. for (let j = 0; j < bangs.length; j++) {
  104. const bang = bangs[j];
  105. bangsMap[bang.dataset.shortcut] = bang;
  106. }
  107. const handleKeyDown = (event) => {
  108. if (event.key == "Escape") {
  109. inputElement.blur();
  110. return;
  111. }
  112. if (event.key == "Enter") {
  113. const input = inputElement.value.trim();
  114. let query;
  115. let searchUrlTemplate;
  116. if (currentBang != null) {
  117. query = input.slice(currentBang.dataset.shortcut.length + 1);
  118. searchUrlTemplate = currentBang.dataset.url;
  119. } else {
  120. query = input;
  121. searchUrlTemplate = defaultSearchUrl;
  122. }
  123. if (query.length == 0 && currentBang == null) {
  124. return;
  125. }
  126. const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
  127. if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
  128. window.open(url, '_blank').focus();
  129. } else {
  130. window.location.href = url;
  131. }
  132. return;
  133. }
  134. };
  135. const changeCurrentBang = (bang) => {
  136. currentBang = bang;
  137. bangElement.textContent = bang != null ? bang.dataset.title : "";
  138. }
  139. const handleInput = (event) => {
  140. const value = event.target.value.trim();
  141. if (value in bangsMap) {
  142. changeCurrentBang(bangsMap[value]);
  143. return;
  144. }
  145. const words = value.split(" ");
  146. if (words.length >= 2 && words[0] in bangsMap) {
  147. changeCurrentBang(bangsMap[words[0]]);
  148. return;
  149. }
  150. changeCurrentBang(null);
  151. };
  152. inputElement.addEventListener("focus", () => {
  153. document.addEventListener("keydown", handleKeyDown);
  154. document.addEventListener("input", handleInput);
  155. });
  156. inputElement.addEventListener("blur", () => {
  157. document.removeEventListener("keydown", handleKeyDown);
  158. document.removeEventListener("input", handleInput);
  159. });
  160. document.addEventListener("keydown", (event) => {
  161. if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
  162. if (event.key != "s") return;
  163. inputElement.focus();
  164. event.preventDefault();
  165. });
  166. kbdElement.addEventListener("mousedown", () => {
  167. requestAnimationFrame(() => inputElement.focus());
  168. });
  169. }
  170. }
  171. function setupDynamicRelativeTime() {
  172. const elements = document.querySelectorAll("[data-dynamic-relative-time]");
  173. const updateInterval = 60 * 1000;
  174. let lastUpdateTime = Date.now();
  175. updateRelativeTimeForElements(elements);
  176. const updateElementsAndTimestamp = () => {
  177. updateRelativeTimeForElements(elements);
  178. lastUpdateTime = Date.now();
  179. };
  180. const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
  181. if (document.hidden === undefined) {
  182. scheduleRepeatingUpdate();
  183. return;
  184. }
  185. let timeout = scheduleRepeatingUpdate();
  186. document.addEventListener("visibilitychange", () => {
  187. if (document.hidden) {
  188. clearTimeout(timeout);
  189. return;
  190. }
  191. const delta = Date.now() - lastUpdateTime;
  192. if (delta >= updateInterval) {
  193. updateElementsAndTimestamp();
  194. timeout = scheduleRepeatingUpdate();
  195. return;
  196. }
  197. timeout = setTimeout(() => {
  198. updateElementsAndTimestamp();
  199. timeout = scheduleRepeatingUpdate();
  200. }, updateInterval - delta);
  201. });
  202. }
  203. function setupGroups() {
  204. const groups = document.getElementsByClassName("widget-type-group");
  205. if (groups.length == 0) {
  206. return;
  207. }
  208. for (let g = 0; g < groups.length; g++) {
  209. const group = groups[g];
  210. const titles = group.getElementsByClassName("widget-header")[0].children;
  211. const tabs = group.getElementsByClassName("widget-group-contents")[0].children;
  212. let current = 0;
  213. for (let t = 0; t < titles.length; t++) {
  214. const title = titles[t];
  215. title.addEventListener("click", () => {
  216. if (t == current) {
  217. return;
  218. }
  219. for (let i = 0; i < titles.length; i++) {
  220. titles[i].classList.remove("widget-group-title-current");
  221. tabs[i].classList.remove("widget-group-content-current");
  222. }
  223. if (current < t) {
  224. tabs[t].dataset.direction = "right";
  225. } else {
  226. tabs[t].dataset.direction = "left";
  227. }
  228. current = t;
  229. title.classList.add("widget-group-title-current");
  230. tabs[t].classList.add("widget-group-content-current");
  231. });
  232. }
  233. }
  234. }
  235. function setupLazyImages() {
  236. const images = document.querySelectorAll("img[loading=lazy]");
  237. if (images.length == 0) {
  238. return;
  239. }
  240. function imageFinishedTransition(image) {
  241. image.classList.add("finished-transition");
  242. }
  243. afterContentReady(() => {
  244. setTimeout(() => {
  245. for (let i = 0; i < images.length; i++) {
  246. const image = images[i];
  247. if (image.complete) {
  248. image.classList.add("cached");
  249. setTimeout(() => imageFinishedTransition(image), 1);
  250. } else {
  251. // TODO: also handle error event
  252. image.addEventListener("load", () => {
  253. image.classList.add("loaded");
  254. setTimeout(() => imageFinishedTransition(image), 400);
  255. });
  256. }
  257. }
  258. }, 1);
  259. });
  260. }
  261. function attachExpandToggleButton(collapsibleContainer) {
  262. const showMoreText = "Show more";
  263. const showLessText = "Show less";
  264. let expanded = false;
  265. const button = document.createElement("button");
  266. const icon = document.createElement("span");
  267. icon.classList.add("expand-toggle-button-icon");
  268. const textNode = document.createTextNode(showMoreText);
  269. button.classList.add("expand-toggle-button");
  270. button.append(textNode, icon);
  271. button.addEventListener("click", () => {
  272. expanded = !expanded;
  273. if (expanded) {
  274. collapsibleContainer.classList.add("container-expanded");
  275. button.classList.add("container-expanded");
  276. textNode.nodeValue = showLessText;
  277. return;
  278. }
  279. const topBefore = button.getClientRects()[0].top;
  280. collapsibleContainer.classList.remove("container-expanded");
  281. button.classList.remove("container-expanded");
  282. textNode.nodeValue = showMoreText;
  283. const topAfter = button.getClientRects()[0].top;
  284. if (topAfter > 0)
  285. return;
  286. window.scrollBy({
  287. top: topAfter - topBefore,
  288. behavior: "instant"
  289. });
  290. });
  291. collapsibleContainer.after(button);
  292. return button;
  293. };
  294. function setupCollapsibleLists() {
  295. const collapsibleLists = document.querySelectorAll(".list.collapsible-container");
  296. if (collapsibleLists.length == 0) {
  297. return;
  298. }
  299. for (let i = 0; i < collapsibleLists.length; i++) {
  300. const list = collapsibleLists[i];
  301. if (list.dataset.collapseAfter === undefined) {
  302. continue;
  303. }
  304. const collapseAfter = parseInt(list.dataset.collapseAfter);
  305. if (collapseAfter == -1) {
  306. continue;
  307. }
  308. if (list.children.length <= collapseAfter) {
  309. continue;
  310. }
  311. attachExpandToggleButton(list);
  312. for (let c = collapseAfter; c < list.children.length; c++) {
  313. const child = list.children[c];
  314. child.classList.add("collapsible-item");
  315. child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
  316. }
  317. }
  318. }
  319. function setupCollapsibleGrids() {
  320. const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container");
  321. if (collapsibleGridElements.length == 0) {
  322. return;
  323. }
  324. for (let i = 0; i < collapsibleGridElements.length; i++) {
  325. const gridElement = collapsibleGridElements[i];
  326. if (gridElement.dataset.collapseAfterRows === undefined) {
  327. continue;
  328. }
  329. const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows);
  330. if (collapseAfterRows == -1) {
  331. continue;
  332. }
  333. const getCardsPerRow = () => {
  334. return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row'));
  335. };
  336. const button = attachExpandToggleButton(gridElement);
  337. let cardsPerRow = 2;
  338. const resolveCollapsibleItems = () => {
  339. const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
  340. if (hideItemsAfterIndex >= gridElement.children.length) {
  341. button.style.display = "none";
  342. } else {
  343. button.style.removeProperty("display");
  344. }
  345. let row = 0;
  346. for (let i = 0; i < gridElement.children.length; i++) {
  347. const child = gridElement.children[i];
  348. if (i >= hideItemsAfterIndex) {
  349. child.classList.add("collapsible-item");
  350. child.style.animationDelay = (row * 40).toString() + "ms";
  351. if (i % cardsPerRow + 1 == cardsPerRow) {
  352. row++;
  353. }
  354. } else {
  355. child.classList.remove("collapsible-item");
  356. child.style.removeProperty("animation-delay");
  357. }
  358. }
  359. };
  360. afterContentReady(() => {
  361. cardsPerRow = getCardsPerRow();
  362. resolveCollapsibleItems();
  363. });
  364. window.addEventListener("resize", () => {
  365. const newCardsPerRow = getCardsPerRow();
  366. if (cardsPerRow == newCardsPerRow) {
  367. return;
  368. }
  369. cardsPerRow = newCardsPerRow;
  370. resolveCollapsibleItems();
  371. });
  372. }
  373. }
  374. const contentReadyCallbacks = [];
  375. function afterContentReady(callback) {
  376. contentReadyCallbacks.push(callback);
  377. }
  378. const weekDayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
  379. const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
  380. function makeSettableTimeElement(element, hourFormat) {
  381. const fragment = document.createDocumentFragment();
  382. const hour = document.createElement('span');
  383. const minute = document.createElement('span');
  384. const amPm = document.createElement('span');
  385. fragment.append(hour, document.createTextNode(':'), minute);
  386. if (hourFormat == '12h') {
  387. fragment.append(document.createTextNode(' '), amPm);
  388. }
  389. element.append(fragment);
  390. return (date) => {
  391. const hours = date.getHours();
  392. if (hourFormat == '12h') {
  393. amPm.textContent = hours < 12 ? 'AM' : 'PM';
  394. hour.textContent = hours % 12 || 12;
  395. } else {
  396. hour.textContent = hours < 10 ? '0' + hours : hours;
  397. }
  398. const minutes = date.getMinutes();
  399. minute.textContent = minutes < 10 ? '0' + minutes : minutes;
  400. };
  401. };
  402. function timeInZone(now, zone) {
  403. let timeInZone;
  404. try {
  405. timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone }));
  406. } catch (e) {
  407. // TODO: indicate to the user that this is an invalid timezone
  408. console.error(e);
  409. timeInZone = now
  410. }
  411. const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
  412. return { time: timeInZone, diffInHours: diffInHours };
  413. }
  414. function setupClocks() {
  415. const clocks = document.getElementsByClassName('clock');
  416. if (clocks.length == 0) {
  417. return;
  418. }
  419. const updateCallbacks = [];
  420. for (var i = 0; i < clocks.length; i++) {
  421. const clock = clocks[i];
  422. const hourFormat = clock.dataset.hourFormat;
  423. const localTimeContainer = clock.querySelector('[data-local-time]');
  424. const localDateElement = localTimeContainer.querySelector('[data-date]');
  425. const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]');
  426. const localYearElement = localTimeContainer.querySelector('[data-year]');
  427. const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]');
  428. const setLocalTime = makeSettableTimeElement(
  429. localTimeContainer.querySelector('[data-time]'),
  430. hourFormat
  431. );
  432. updateCallbacks.push((now) => {
  433. setLocalTime(now);
  434. localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()];
  435. localWeekdayElement.textContent = weekDayNames[now.getDay()];
  436. localYearElement.textContent = now.getFullYear();
  437. });
  438. for (var z = 0; z < timeZoneContainers.length; z++) {
  439. const timeZoneContainer = timeZoneContainers[z];
  440. const diffElement = timeZoneContainer.querySelector('[data-time-diff]');
  441. const setZoneTime = makeSettableTimeElement(
  442. timeZoneContainer.querySelector('[data-time]'),
  443. hourFormat
  444. );
  445. updateCallbacks.push((now) => {
  446. const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
  447. setZoneTime(time);
  448. diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
  449. });
  450. }
  451. }
  452. const updateClocks = () => {
  453. const now = new Date();
  454. for (var i = 0; i < updateCallbacks.length; i++)
  455. updateCallbacks[i](now);
  456. setTimeout(updateClocks, (60 - now.getSeconds()) * 1000);
  457. };
  458. updateClocks();
  459. }
  460. async function setupPage() {
  461. const pageElement = document.getElementById("page");
  462. const pageContentElement = document.getElementById("page-content");
  463. const pageContent = await fetchPageContent(pageData);
  464. pageContentElement.innerHTML = pageContent;
  465. try {
  466. setupClocks()
  467. setupCarousels();
  468. setupSearchBoxes();
  469. setupCollapsibleLists();
  470. setupCollapsibleGrids();
  471. setupGroups();
  472. setupDynamicRelativeTime();
  473. setupLazyImages();
  474. } finally {
  475. pageElement.classList.add("content-ready");
  476. for (let i = 0; i < contentReadyCallbacks.length; i++) {
  477. contentReadyCallbacks[i]();
  478. }
  479. setTimeout(() => {
  480. document.body.classList.add("page-columns-transitioned");
  481. }, 300);
  482. }
  483. }
  484. setupPage();