main.js 18 KB

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