main.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  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(pageSlug) {
  20. // TODO: handle non 200 status codes/time outs
  21. // TODO: add retries
  22. const response = await fetch(`/api/pages/${pageSlug}/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 setupDynamicRelativeTime() {
  89. const elements = document.querySelectorAll("[data-dynamic-relative-time]");
  90. const updateInterval = 60 * 1000;
  91. let lastUpdateTime = Date.now();
  92. updateRelativeTimeForElements(elements);
  93. const updateElementsAndTimestamp = () => {
  94. updateRelativeTimeForElements(elements);
  95. lastUpdateTime = Date.now();
  96. };
  97. const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
  98. if (document.hidden === undefined) {
  99. scheduleRepeatingUpdate();
  100. return;
  101. }
  102. let timeout = scheduleRepeatingUpdate();
  103. document.addEventListener("visibilitychange", () => {
  104. if (document.hidden) {
  105. clearTimeout(timeout);
  106. return;
  107. }
  108. const delta = Date.now() - lastUpdateTime;
  109. if (delta >= updateInterval) {
  110. updateElementsAndTimestamp();
  111. timeout = scheduleRepeatingUpdate();
  112. return;
  113. }
  114. timeout = setTimeout(() => {
  115. updateElementsAndTimestamp();
  116. timeout = scheduleRepeatingUpdate();
  117. }, updateInterval - delta);
  118. });
  119. }
  120. function setupLazyImages() {
  121. const images = document.querySelectorAll("img[loading=lazy]");
  122. if (images.length == 0) {
  123. return;
  124. }
  125. function imageFinishedTransition(image) {
  126. image.classList.add("finished-transition");
  127. }
  128. afterContentReady(() => {
  129. setTimeout(() => {
  130. for (let i = 0; i < images.length; i++) {
  131. const image = images[i];
  132. if (image.complete) {
  133. image.classList.add("cached");
  134. setTimeout(() => imageFinishedTransition(image), 1);
  135. } else {
  136. // TODO: also handle error event
  137. image.addEventListener("load", () => {
  138. image.classList.add("loaded");
  139. setTimeout(() => imageFinishedTransition(image), 400);
  140. });
  141. }
  142. }
  143. }, 1);
  144. });
  145. }
  146. function attachExpandToggleButton(collapsibleContainer) {
  147. const showMoreText = "Show more";
  148. const showLessText = "Show less";
  149. let expanded = false;
  150. const button = document.createElement("button");
  151. const icon = document.createElement("span");
  152. icon.classList.add("expand-toggle-button-icon");
  153. const textNode = document.createTextNode(showMoreText);
  154. button.classList.add("expand-toggle-button");
  155. button.append(textNode, icon);
  156. button.addEventListener("click", () => {
  157. expanded = !expanded;
  158. if (expanded) {
  159. collapsibleContainer.classList.add("container-expanded");
  160. button.classList.add("container-expanded");
  161. textNode.nodeValue = showLessText;
  162. return;
  163. }
  164. const topBefore = button.getClientRects()[0].top;
  165. collapsibleContainer.classList.remove("container-expanded");
  166. button.classList.remove("container-expanded");
  167. textNode.nodeValue = showMoreText;
  168. const topAfter = button.getClientRects()[0].top;
  169. if (topAfter > 0)
  170. return;
  171. window.scrollBy({
  172. top: topAfter - topBefore,
  173. behavior: "instant"
  174. });
  175. });
  176. collapsibleContainer.after(button);
  177. return button;
  178. };
  179. function setupCollapsibleLists() {
  180. const collapsibleLists = document.querySelectorAll(".list.collapsible-container");
  181. if (collapsibleLists.length == 0) {
  182. return;
  183. }
  184. for (let i = 0; i < collapsibleLists.length; i++) {
  185. const list = collapsibleLists[i];
  186. if (list.dataset.collapseAfter === undefined) {
  187. continue;
  188. }
  189. const collapseAfter = parseInt(list.dataset.collapseAfter);
  190. if (collapseAfter == -1) {
  191. continue;
  192. }
  193. if (list.children.length <= collapseAfter) {
  194. continue;
  195. }
  196. attachExpandToggleButton(list);
  197. for (let c = collapseAfter; c < list.children.length; c++) {
  198. const child = list.children[c];
  199. child.classList.add("collapsible-item");
  200. child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
  201. }
  202. }
  203. }
  204. function setupCollapsibleGrids() {
  205. const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container");
  206. if (collapsibleGridElements.length == 0) {
  207. return;
  208. }
  209. for (let i = 0; i < collapsibleGridElements.length; i++) {
  210. const gridElement = collapsibleGridElements[i];
  211. if (gridElement.dataset.collapseAfterRows === undefined) {
  212. continue;
  213. }
  214. const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows);
  215. if (collapseAfterRows == -1) {
  216. continue;
  217. }
  218. const getCardsPerRow = () => {
  219. return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row'));
  220. };
  221. const button = attachExpandToggleButton(gridElement);
  222. let cardsPerRow = 2;
  223. const resolveCollapsibleItems = () => {
  224. const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
  225. if (hideItemsAfterIndex >= gridElement.children.length) {
  226. button.style.display = "none";
  227. } else {
  228. button.style.removeProperty("display");
  229. }
  230. let row = 0;
  231. for (let i = 0; i < gridElement.children.length; i++) {
  232. const child = gridElement.children[i];
  233. if (i >= hideItemsAfterIndex) {
  234. child.classList.add("collapsible-item");
  235. child.style.animationDelay = (row * 40).toString() + "ms";
  236. if (i % cardsPerRow + 1 == cardsPerRow) {
  237. row++;
  238. }
  239. } else {
  240. child.classList.remove("collapsible-item");
  241. child.style.removeProperty("animation-delay");
  242. }
  243. }
  244. };
  245. afterContentReady(() => {
  246. cardsPerRow = getCardsPerRow();
  247. resolveCollapsibleItems();
  248. });
  249. window.addEventListener("resize", () => {
  250. const newCardsPerRow = getCardsPerRow();
  251. if (cardsPerRow == newCardsPerRow) {
  252. return;
  253. }
  254. cardsPerRow = newCardsPerRow;
  255. resolveCollapsibleItems();
  256. });
  257. }
  258. }
  259. const contentReadyCallbacks = [];
  260. function afterContentReady(callback) {
  261. contentReadyCallbacks.push(callback);
  262. }
  263. const weekDayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
  264. const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
  265. function makeSettableTimeElement(element, hourFormat) {
  266. const fragment = document.createDocumentFragment();
  267. const hour = document.createElement('span');
  268. const minute = document.createElement('span');
  269. const amPm = document.createElement('span');
  270. fragment.append(hour, document.createTextNode(':'), minute);
  271. if (hourFormat == '12h') {
  272. fragment.append(document.createTextNode(' '), amPm);
  273. }
  274. element.append(fragment);
  275. return (date) => {
  276. const hours = date.getHours();
  277. if (hourFormat == '12h') {
  278. amPm.textContent = hours < 12 ? 'AM' : 'PM';
  279. hour.textContent = hours % 12 || 12;
  280. } else {
  281. hour.textContent = hours < 10 ? '0' + hours : hours;
  282. }
  283. const minutes = date.getMinutes();
  284. minute.textContent = minutes < 10 ? '0' + minutes : minutes;
  285. };
  286. };
  287. function timeInZone(now, zone) {
  288. let timeInZone;
  289. try {
  290. timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone }));
  291. } catch (e) {
  292. // TODO: indicate to the user that this is an invalid timezone
  293. console.error(e);
  294. timeInZone = now
  295. }
  296. const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
  297. return { time: timeInZone, diffInHours: diffInHours };
  298. }
  299. function setupClocks() {
  300. const clocks = document.getElementsByClassName('clock');
  301. if (clocks.length == 0) {
  302. return;
  303. }
  304. const updateCallbacks = [];
  305. for (var i = 0; i < clocks.length; i++) {
  306. const clock = clocks[i];
  307. const hourFormat = clock.dataset.hourFormat;
  308. const localTimeContainer = clock.querySelector('[data-local-time]');
  309. const localDateElement = localTimeContainer.querySelector('[data-date]');
  310. const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]');
  311. const localYearElement = localTimeContainer.querySelector('[data-year]');
  312. const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]');
  313. const setLocalTime = makeSettableTimeElement(
  314. localTimeContainer.querySelector('[data-time]'),
  315. hourFormat
  316. );
  317. updateCallbacks.push((now) => {
  318. setLocalTime(now);
  319. localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()];
  320. localWeekdayElement.textContent = weekDayNames[now.getDay()];
  321. localYearElement.textContent = now.getFullYear();
  322. });
  323. for (var z = 0; z < timeZoneContainers.length; z++) {
  324. const timeZoneContainer = timeZoneContainers[z];
  325. const diffElement = timeZoneContainer.querySelector('[data-time-diff]');
  326. const setZoneTime = makeSettableTimeElement(
  327. timeZoneContainer.querySelector('[data-time]'),
  328. hourFormat
  329. );
  330. updateCallbacks.push((now) => {
  331. const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
  332. setZoneTime(time);
  333. diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
  334. });
  335. }
  336. }
  337. const updateClocks = () => {
  338. const now = new Date();
  339. for (var i = 0; i < updateCallbacks.length; i++)
  340. updateCallbacks[i](now);
  341. setTimeout(updateClocks, (60 - now.getSeconds()) * 1000);
  342. };
  343. updateClocks();
  344. }
  345. async function setupPage() {
  346. const pageElement = document.getElementById("page");
  347. const pageContentElement = document.getElementById("page-content");
  348. const pageContent = await fetchPageContent(pageData.slug);
  349. pageContentElement.innerHTML = pageContent;
  350. try {
  351. setupClocks()
  352. setupCarousels();
  353. setupCollapsibleLists();
  354. setupCollapsibleGrids();
  355. setupDynamicRelativeTime();
  356. setupLazyImages();
  357. } finally {
  358. pageElement.classList.add("content-ready");
  359. for (let i = 0; i < contentReadyCallbacks.length; i++) {
  360. contentReadyCallbacks[i]();
  361. }
  362. setTimeout(() => {
  363. document.body.classList.add("page-columns-transitioned");
  364. }, 300);
  365. }
  366. }
  367. if (document.readyState === "loading") {
  368. document.addEventListener("DOMContentLoaded", setupPage);
  369. } else {
  370. setupPage();
  371. }