main.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808
  1. // log htmx on dev
  2. // htmx.logAll();
  3. // add text/html accept header to receive html instead of json for the requests
  4. document.body.addEventListener('htmx:configRequest', function(evt) {
  5. evt.detail.headers["Accept"] = "text/html,*/*";
  6. });
  7. // redirect to homepage
  8. document.body.addEventListener("redirectToHomepage", function() {
  9. setTimeout(() => {
  10. window.location.replace("/");
  11. }, 1500);
  12. });
  13. // reset form if event is sent from the backend
  14. function resetForm(id) {
  15. return function() {
  16. const form = document.getElementById(id);
  17. if (!form) return;
  18. form.reset();
  19. }
  20. }
  21. document.body.addEventListener('resetChangePasswordForm', resetForm("change-password"));
  22. document.body.addEventListener('resetChangeEmailForm', resetForm("change-email"));
  23. // an htmx extension to use the specifed params in the path instead of the query or body
  24. htmx.defineExtension("path-params", {
  25. onEvent: function(name, evt) {
  26. if (name === "htmx:configRequest") {
  27. evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function(_, param) {
  28. var val = evt.detail.parameters[param]
  29. delete evt.detail.parameters[param]
  30. return val === undefined ? '{' + param + '}' : encodeURIComponent(val)
  31. })
  32. }
  33. }
  34. })
  35. // find closest element
  36. function closest(selector, elm) {
  37. let element = elm || this;
  38. while (element && element.nodeType === 1) {
  39. if (element.matches(selector)) {
  40. return element;
  41. }
  42. element = element.parentNode;
  43. }
  44. return null;
  45. };
  46. // get url query param
  47. function getQueryParams() {
  48. const search = window.location.search.replace("?", "");
  49. const query = {};
  50. search.split("&").map(q => {
  51. const keyvalue = q.split("=");
  52. query[keyvalue[0]] = keyvalue[1];
  53. });
  54. return query;
  55. }
  56. // trim text
  57. function trimText(selector, length) {
  58. const element = document.querySelector(selector);
  59. if (!element) return;
  60. let text = element.textContent;
  61. if (typeof text !== "string") return;
  62. text = text.trim();
  63. if (text.length > length) {
  64. element.textContent = text.split("").slice(0, length).join("") + "...";
  65. }
  66. }
  67. function formatDateHour(selector) {
  68. const element = document.querySelector(selector);
  69. if (!element) return;
  70. const dateString = element.dataset.date;
  71. if (!dateString) return;
  72. const date = new Date(dateString);
  73. element.textContent = date.getHours() + ":" + date.getMinutes();
  74. }
  75. // show QR code
  76. function handleQRCode(element, id) {
  77. const dialog = document.getElementById(id);
  78. const dialogContent = dialog.querySelector(".content-wrapper");
  79. if (!dialogContent) return;
  80. openDialog(id, "qrcode");
  81. dialogContent.textContent = "";
  82. const qrcode = new QRCode(dialogContent, {
  83. text: element.dataset.url,
  84. width: 200,
  85. height: 200,
  86. colorDark : "#000000",
  87. colorLight : "#ffffff",
  88. correctLevel : QRCode.CorrectLevel.H
  89. });
  90. }
  91. // copy the link to clipboard
  92. function handleCopyLink(element) {
  93. navigator.clipboard.writeText(element.dataset.url);
  94. }
  95. // copy the link and toggle copy button style
  96. function handleShortURLCopyLink(element) {
  97. handleCopyLink(element);
  98. const clipboard = element.parentNode.querySelector(".clipboard") || closest(".clipboard", element);
  99. if (!clipboard || clipboard.classList.contains("copied")) return;
  100. clipboard.classList.add("copied");
  101. setTimeout(function() {
  102. clipboard.classList.remove("copied");
  103. }, 1000);
  104. }
  105. // open and close dialog
  106. function openDialog(id, name) {
  107. const dialog = document.getElementById(id);
  108. if (!dialog) return;
  109. dialog.classList.add("open");
  110. if (name) {
  111. dialog.classList.add(name);
  112. }
  113. }
  114. function closeDialog() {
  115. const dialog = document.querySelector(".dialog");
  116. if (!dialog) return;
  117. while (dialog.classList.length > 0) {
  118. dialog.classList.remove(dialog.classList[0]);
  119. }
  120. dialog.classList.add("dialog");
  121. }
  122. window.addEventListener("click", function(event) {
  123. const dialog = document.querySelector(".dialog");
  124. if (dialog && event.target === dialog) {
  125. closeDialog();
  126. }
  127. });
  128. // handle navigation in the table of links
  129. function setLinksLimit(event) {
  130. const buttons = Array.from(document.querySelectorAll('table .nav .limit button'));
  131. const limitInput = document.querySelector('#limit');
  132. if (!limitInput || !buttons || !buttons.length) return;
  133. limitInput.value = event.target.textContent;
  134. buttons.forEach(b => {
  135. b.disabled = b.textContent === event.target.textContent;
  136. });
  137. }
  138. function setLinksSkip(event, action) {
  139. const buttons = Array.from(document.querySelectorAll('table .nav .pagination button'));
  140. const limitElm = document.querySelector('#limit');
  141. const totalElm = document.querySelector('#total');
  142. const skipElm = document.querySelector('#skip');
  143. if (!buttons || !limitElm || !totalElm || !skipElm) return;
  144. const skip = parseInt(skipElm.value);
  145. const limit = parseInt(limitElm.value);
  146. const total = parseInt(totalElm.value);
  147. skipElm.value = action === "next" ? skip + limit : Math.max(skip - limit, 0);
  148. document.querySelectorAll('.pagination .next').forEach(elm => {
  149. elm.disabled = total <= parseInt(skipElm.value) + limit;
  150. });
  151. document.querySelectorAll('.pagination .prev').forEach(elm => {
  152. elm.disabled = parseInt(skipElm.value) <= 0;
  153. });
  154. }
  155. function updateLinksNav() {
  156. const totalElm = document.querySelector('#total');
  157. const skipElm = document.querySelector('#skip');
  158. const limitElm = document.querySelector('#limit');
  159. if (!totalElm || !skipElm || !limitElm) return;
  160. const total = parseInt(totalElm.value);
  161. const skip = parseInt(skipElm.value);
  162. const limit = parseInt(limitElm.value);
  163. document.querySelectorAll('.pagination .next').forEach(elm => {
  164. elm.disabled = total <= skip + limit;
  165. });
  166. document.querySelectorAll('.pagination .prev').forEach(elm => {
  167. elm.disabled = skip <= 0;
  168. });
  169. }
  170. function resetTableNav() {
  171. const totalElm = document.querySelector('#total');
  172. const skipElm = document.querySelector('#skip');
  173. const limitElm = document.querySelector('#limit');
  174. if (!totalElm || !skipElm || !limitElm) return;
  175. skipElm.value = 0;
  176. limitElm.value = 10;
  177. const total = parseInt(totalElm.value);
  178. const skip = parseInt(skipElm.value);
  179. const limit = parseInt(limitElm.value);
  180. document.querySelectorAll('.pagination .next').forEach(elm => {
  181. elm.disabled = total <= skip + limit;
  182. });
  183. document.querySelectorAll('.pagination .prev').forEach(elm => {
  184. elm.disabled = skip <= 0;
  185. });
  186. document.querySelectorAll('table .nav .limit button').forEach(b => {
  187. b.disabled = b.textContent === limit.toString();
  188. });
  189. }
  190. // tab click
  191. function setTab(event, targetId) {
  192. const tabs = Array.from(closest("nav", event.target).children);
  193. tabs.forEach(function (tab) {
  194. tab.classList.remove("active");
  195. });
  196. if (targetId) {
  197. document.getElementById(targetId).classList.add("active");
  198. } else {
  199. event.target.classList.add("active");
  200. }
  201. }
  202. // show clear search button
  203. function onSearchChange(event) {
  204. const clearButton = event.target.parentElement.querySelector("button.clear");
  205. if (!clearButton) return;
  206. clearButton.style.display = event.target.value.length > 0 ? "block" : "none";
  207. }
  208. function clearSeachInput(event) {
  209. event.preventDefault();
  210. const button = closest("button", event.target);
  211. const input = button.parentElement.querySelector("input");
  212. if (!input) return;
  213. input.value = "";
  214. button.style.display = "none";
  215. htmx.trigger("body", "reloadMainTable");
  216. }
  217. // detect if search inputs have value on load to show clear button
  218. function onSearchInputLoad() {
  219. const linkSearchInput = document.getElementById("search");
  220. if (!linkSearchInput) return;
  221. const linkClearButton = linkSearchInput.parentElement.querySelector("button.clear")
  222. linkClearButton.style.display = linkSearchInput.value.length > 0 ? "block" : "none";
  223. const userSearchInput = document.getElementById("search_user");
  224. if (!userSearchInput) return;
  225. const userClearButton = userSearchInput.parentElement.querySelector("button.clear")
  226. userClearButton.style.display = userSearchInput.value.length > 0 ? "block" : "none";
  227. const domainSearchInput = document.getElementById("search_domain");
  228. if (!domainSearchInput) return;
  229. const domainClearButton = domainSearchInput.parentElement.querySelector("button.clear")
  230. domainClearButton.style.display = domainSearchInput.value.length > 0 ? "block" : "none";
  231. }
  232. onSearchInputLoad();
  233. // create user checkbox control
  234. function canSendVerificationEmail() {
  235. const canSendVerificationEmail = !document.getElementById('create-user-verified').checked && !document.getElementById('create-user-banned').checked;
  236. const checkbox = document.getElementById('send-email-label');
  237. if (canSendVerificationEmail)
  238. checkbox.classList.remove('hidden');
  239. if (!canSendVerificationEmail && !checkbox.classList.contains('hidden'))
  240. checkbox.classList.add('hidden');
  241. }
  242. // create views chart label
  243. function createViewsChartLabel(ctx) {
  244. const period = ctx.dataset.period;
  245. let labels = [];
  246. if (period === "day") {
  247. const nowHour = new Date().getHours();
  248. for (let i = 23; i >= 0; --i) {
  249. let h = nowHour - i;
  250. if (h < 0) h = 24 + h;
  251. labels.push(`${Math.floor(h)}:00`);
  252. }
  253. }
  254. if (period === "week") {
  255. const nowDay = new Date().getDate();
  256. for (let i = 6; i >= 0; --i) {
  257. const date = new Date(new Date().setDate(nowDay - i));
  258. labels.push(`${date.getDate()} ${date.toLocaleString("default",{month:"short"})}`);
  259. }
  260. }
  261. if (period === "month") {
  262. const nowDay = new Date().getDate();
  263. for (let i = 29; i >= 0; --i) {
  264. const date = new Date(new Date().setDate(nowDay - i));
  265. labels.push(`${date.getDate()} ${date.toLocaleString("default",{month:"short"})}`);
  266. }
  267. }
  268. if (period === "year") {
  269. const nowMonth = new Date().getMonth();
  270. for (let i = 11; i >= 0; --i) {
  271. const date = new Date(new Date().setMonth(nowMonth - i));
  272. labels.push(`${date.toLocaleString("default",{month:"short"})} ${date.toLocaleString("default",{year:"numeric"})}`);
  273. }
  274. }
  275. return labels;
  276. }
  277. // create views chart
  278. function createViewsChart() {
  279. const canvases = document.querySelectorAll("canvas.visits");
  280. if (!canvases || !canvases.length) return;
  281. canvases.forEach(ctx => {
  282. const data = JSON.parse(ctx.dataset.data);
  283. const period = ctx.dataset.period;
  284. const labels = createViewsChartLabel(ctx);
  285. const maxTicksLimitX = period === "year" ? 6 : period === "month" ? 15 : 12;
  286. const gradient = ctx.getContext("2d").createLinearGradient(0, 0, 0, 300);
  287. gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");
  288. gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
  289. new Chart(ctx, {
  290. type: "line",
  291. data: {
  292. labels: labels,
  293. datasets: [{
  294. label: "Views",
  295. data,
  296. tension: 0.3,
  297. elements: {
  298. point: {
  299. pointRadius: 0,
  300. pointHoverRadius: 4
  301. }
  302. },
  303. fill: {
  304. target: "start",
  305. },
  306. backgroundColor: gradient,
  307. borderColor: "rgb(179, 157, 219)",
  308. borderWidth: 1,
  309. }]
  310. },
  311. options: {
  312. plugins: {
  313. legend: {
  314. display: false,
  315. },
  316. tooltip: {
  317. backgroundColor: "rgba(255, 255, 255, 0.95)",
  318. titleColor: "#333",
  319. titleFont: { weight: "normal", size: 15 },
  320. bodyFont: { weight: "normal", size: 16 },
  321. bodyColor: "rgb(179, 157, 219)",
  322. padding: 12,
  323. cornerRadius: 2,
  324. borderColor: "rgba(0, 0, 0, 0.1)",
  325. borderWidth: 1,
  326. displayColors: false,
  327. }
  328. },
  329. responsive: true,
  330. interaction: {
  331. intersect: false,
  332. usePointStyle: true,
  333. mode: "index",
  334. },
  335. scales: {
  336. y: {
  337. grace: "10%",
  338. beginAtZero: true,
  339. ticks: {
  340. maxTicksLimit: 5
  341. }
  342. },
  343. x: {
  344. ticks: {
  345. maxTicksLimit: maxTicksLimitX,
  346. }
  347. }
  348. }
  349. }
  350. });
  351. // reset the display: block style that chart.js applies automatically
  352. ctx.style.display = "";
  353. });
  354. }
  355. // beautify browser lables
  356. function beautifyBrowserName(name) {
  357. if (name === "firefox") return "Firefox";
  358. if (name === "chrome") return "Chrome";
  359. if (name === "edge") return "Edge";
  360. if (name === "opera") return "Opera";
  361. if (name === "safari") return "Safari";
  362. if (name === "other") return "Other";
  363. if (name === "ie") return "IE";
  364. return name;
  365. }
  366. // create browsers chart
  367. function createBrowsersChart() {
  368. const canvases = document.querySelectorAll("canvas.browsers");
  369. if (!canvases || !canvases.length) return;
  370. canvases.forEach(ctx => {
  371. const data = JSON.parse(ctx.dataset.data);
  372. const period = ctx.dataset.period;
  373. const gradient = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
  374. const gradientHover = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
  375. gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");
  376. gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
  377. gradientHover.addColorStop(0, "rgba(179, 157, 219, 0.9)");
  378. gradientHover.addColorStop(1, "rgba(179, 157, 219, 0.4)");
  379. new Chart(ctx, {
  380. type: "bar",
  381. data: {
  382. labels: data.map(d => beautifyBrowserName(d.name)),
  383. datasets: [{
  384. label: "Views",
  385. data: data.map(d => d.value),
  386. backgroundColor: gradient,
  387. borderColor: "rgba(179, 157, 219, 1)",
  388. borderWidth: 1,
  389. hoverBackgroundColor: gradientHover,
  390. hoverBorderWidth: 2
  391. }]
  392. },
  393. options: {
  394. indexAxis: "y",
  395. plugins: {
  396. legend: {
  397. display: false,
  398. },
  399. tooltip: {
  400. backgroundColor: "rgba(255, 255, 255, 0.95)",
  401. titleColor: "#333",
  402. titleFont: { weight: "normal", size: 15 },
  403. bodyFont: { weight: "normal", size: 16 },
  404. bodyColor: "rgb(179, 157, 219)",
  405. padding: 12,
  406. cornerRadius: 2,
  407. borderColor: "rgba(0, 0, 0, 0.1)",
  408. borderWidth: 1,
  409. displayColors: false,
  410. }
  411. },
  412. responsive: true,
  413. interaction: {
  414. intersect: false,
  415. mode: "index",
  416. axis: "y"
  417. },
  418. scales: {
  419. x: {
  420. grace: "5%",
  421. beginAtZero: true,
  422. ticks: {
  423. maxTicksLimit: 6,
  424. }
  425. }
  426. }
  427. }
  428. });
  429. // reset the display: block style that chart.js applies automatically
  430. ctx.style.display = "";
  431. });
  432. }
  433. // create referrers chart
  434. function createReferrersChart() {
  435. const canvases = document.querySelectorAll("canvas.referrers");
  436. if (!canvases || !canvases.length) return;
  437. canvases.forEach(ctx => {
  438. const data = JSON.parse(ctx.dataset.data);
  439. const period = ctx.dataset.period;
  440. let max = Array.from(data).sort((a, b) => a.value > b.value ? -1 : 1)[0];
  441. let tooltipEnabled = true;
  442. let hoverBackgroundColor = "rgba(179, 157, 219, 1)";
  443. let hoverBorderWidth = 2;
  444. let borderColor = "rgba(179, 157, 219, 1)";
  445. if (data.length === 0) {
  446. data.push({ name: "No views.", value: 1 });
  447. max = { value: 1000 };
  448. tooltipEnabled = false;
  449. hoverBackgroundColor = "rgba(179, 157, 219, 0.1)";
  450. hoverBorderWidth = 1;
  451. borderColor = "rgba(179, 157, 219, 0.2)";
  452. }
  453. new Chart(ctx, {
  454. type: "doughnut",
  455. data: {
  456. labels: data.map(d => d.name.replace(/\[dot\]/g, ".")),
  457. datasets: [{
  458. label: "Views",
  459. data: data.map(d => d.value),
  460. backgroundColor: data.map(d => `rgba(179, 157, 219, ${Math.max((d.value / max.value) - 0.2, 0.1).toFixed(2)})`),
  461. borderWidth: 1,
  462. borderColor,
  463. hoverBackgroundColor,
  464. hoverBorderWidth,
  465. }]
  466. },
  467. options: {
  468. plugins: {
  469. legend: {
  470. position: "left",
  471. labels: {
  472. boxWidth: 25,
  473. font: { size: 11 }
  474. }
  475. },
  476. tooltip: {
  477. enabled: tooltipEnabled,
  478. backgroundColor: "rgba(255, 255, 255, 0.95)",
  479. titleColor: "#333",
  480. titleFont: { weight: "normal", size: 15 },
  481. bodyFont: { weight: "normal", size: 16 },
  482. bodyColor: "rgb(179, 157, 219)",
  483. padding: 12,
  484. cornerRadius: 2,
  485. borderColor: "rgba(0, 0, 0, 0.1)",
  486. borderWidth: 1,
  487. displayColors: false,
  488. }
  489. },
  490. responsive: false,
  491. }
  492. });
  493. // reset the display: block style that chart.js applies automatically
  494. ctx.style.display = "";
  495. });
  496. }
  497. // beautify browser lables
  498. function beautifyOsName(name) {
  499. if (name === "android") return "Android";
  500. if (name === "ios") return "iOS";
  501. if (name === "linux") return "Linux";
  502. if (name === "macos") return "macOS";
  503. if (name === "windows") return "Windows";
  504. if (name === "other") return "Other";
  505. return name;
  506. }
  507. // create operation systems chart
  508. function createOsChart() {
  509. const canvases = document.querySelectorAll("canvas.os");
  510. if (!canvases || !canvases.length) return;
  511. canvases.forEach(ctx => {
  512. const data = JSON.parse(ctx.dataset.data);
  513. const period = ctx.dataset.period;
  514. const gradient = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
  515. const gradientHover = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
  516. gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");
  517. gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
  518. gradientHover.addColorStop(0, "rgba(179, 157, 219, 0.9)");
  519. gradientHover.addColorStop(1, "rgba(179, 157, 219, 0.4)");
  520. new Chart(ctx, {
  521. type: "bar",
  522. data: {
  523. labels: data.map(d => beautifyOsName(d.name)),
  524. datasets: [{
  525. label: "Views",
  526. data: data.map(d => d.value),
  527. backgroundColor: gradient,
  528. borderColor: "rgba(179, 157, 219, 1)",
  529. borderWidth: 1,
  530. hoverBackgroundColor: gradientHover,
  531. hoverBorderWidth: 2
  532. }]
  533. },
  534. options: {
  535. indexAxis: "y",
  536. plugins: {
  537. legend: {
  538. display: false,
  539. },
  540. tooltip: {
  541. backgroundColor: "rgba(255, 255, 255, 0.95)",
  542. titleColor: "#333",
  543. titleFont: { weight: "normal", size: 15 },
  544. bodyFont: { weight: "normal", size: 16 },
  545. bodyColor: "rgb(179, 157, 219)",
  546. padding: 12,
  547. cornerRadius: 2,
  548. borderColor: "rgba(0, 0, 0, 0.1)",
  549. borderWidth: 1,
  550. displayColors: false,
  551. }
  552. },
  553. responsive: true,
  554. interaction: {
  555. intersect: false,
  556. mode: "index",
  557. axis: "y"
  558. },
  559. scales: {
  560. x: {
  561. grace:"5%",
  562. beginAtZero: true,
  563. ticks: {
  564. maxTicksLimit: 6,
  565. }
  566. }
  567. }
  568. }
  569. });
  570. // reset the display: block style that chart.js applies automatically
  571. ctx.style.display = "";
  572. });
  573. }
  574. // add data to the map
  575. function feedMapData(period) {
  576. const map = document.querySelector("svg.map");
  577. const paths = map.querySelectorAll("path");
  578. if (!map || !paths || !paths.length) return;
  579. let data = JSON.parse(map.dataset[period || "day"]);
  580. if (!data) return;
  581. let max = data.sort((a, b) => a.value > b.value ? -1 : 1)[0];
  582. if (!max) max = { value: 1 }
  583. data = data.reduce((a, c) => ({ ...a, [c.name]: c.value }), {});
  584. for (let i = 0; i < paths.length; ++i) {
  585. const id = paths[i].dataset.id;
  586. const views = data[id] || 0;
  587. paths[i].dataset.views = views;
  588. const colorLevel = Math.ceil((views / max.value) * 6);
  589. const classList = paths[i].classList;
  590. for (let j = 1; j < 7; j++) {
  591. paths[i].classList.remove(`color-${j}`);
  592. }
  593. paths[i].classList.add(`color-${colorLevel}`)
  594. paths[i].dataset.views = views;
  595. }
  596. }
  597. // handle map tooltip hover
  598. function mapTooltipHoverOver() {
  599. const tooltip = document.querySelector("#map-tooltip");
  600. if (!tooltip) return;
  601. if (!event.target.dataset.id) return mapTooltipHoverOut();
  602. if (!tooltip.classList.contains("active")) {
  603. tooltip.classList.add("visible");
  604. }
  605. tooltip.dataset.tooltip = `${event.target.ariaLabel}: ${event.target.dataset.views || 0}`;
  606. const rect = event.target.getBoundingClientRect();
  607. tooltip.style.top = rect.top + (rect.height / 2) + "px";
  608. tooltip.style.left = rect.left + (rect.width / 2) + "px";
  609. event.target.classList.add("active");
  610. }
  611. function mapTooltipHoverOut() {
  612. const tooltip = document.querySelector("#map-tooltip");
  613. const map = document.querySelector("svg.map");
  614. const paths = map.querySelectorAll("path");
  615. if (!tooltip || !map) return;
  616. tooltip.classList.remove("visible");
  617. for (let i = 0; i < paths.length; ++i) {
  618. paths[i].classList.remove("active");
  619. }
  620. }
  621. // create stats charts
  622. function createCharts() {
  623. createViewsChart();
  624. createBrowsersChart();
  625. createReferrersChart();
  626. createOsChart();
  627. feedMapData();
  628. }
  629. // change stats period for showing charts and data
  630. function changeStatsPeriod(event) {
  631. const period = event.target.dataset.period;
  632. if (!period) return;
  633. const canvases = document.querySelector("#stats").querySelectorAll("[data-period]");
  634. const buttons = document.querySelector("#stats").querySelectorAll(".nav");
  635. if (!buttons || !canvases) return;
  636. buttons.forEach(b => b.disabled = false);
  637. event.target.disabled = true;
  638. canvases.forEach(canvas => {
  639. if (canvas.dataset.period === period) {
  640. canvas.classList.remove("hidden");
  641. } else {
  642. canvas.classList.add("hidden");
  643. }
  644. });
  645. feedMapData(period);
  646. }
  647. // htmx prefetch extension
  648. // https://github.com/bigskysoftware/htmx-extensions/blob/main/src/preload/README.md
  649. htmx.defineExtension('preload', {
  650. onEvent: function(name, event) {
  651. if (name !== 'htmx:afterProcessNode') {
  652. return
  653. }
  654. var attr = function(node, property) {
  655. if (node == undefined) { return undefined }
  656. return node.getAttribute(property) || node.getAttribute('data-' + property) || attr(node.parentElement, property)
  657. }
  658. var load = function(node) {
  659. var done = function(html) {
  660. if (!node.preloadAlways) {
  661. node.preloadState = 'DONE'
  662. }
  663. if (attr(node, 'preload-images') == 'true') {
  664. document.createElement('div').innerHTML = html
  665. }
  666. }
  667. return function() {
  668. if (node.preloadState !== 'READY') {
  669. return
  670. }
  671. var hxGet = node.getAttribute('hx-get') || node.getAttribute('data-hx-get')
  672. if (hxGet) {
  673. htmx.ajax('GET', hxGet, {
  674. source: node,
  675. handler: function(elt, info) {
  676. done(info.xhr.responseText)
  677. }
  678. })
  679. return
  680. }
  681. if (node.getAttribute('href')) {
  682. var r = new XMLHttpRequest()
  683. r.open('GET', node.getAttribute('href'))
  684. r.onload = function() { done(r.responseText) }
  685. r.send()
  686. }
  687. }
  688. }
  689. var init = function(node) {
  690. if (node.getAttribute('href') + node.getAttribute('hx-get') + node.getAttribute('data-hx-get') == '') {
  691. return
  692. }
  693. if (node.preloadState !== undefined) {
  694. return
  695. }
  696. var on = attr(node, 'preload') || 'mousedown'
  697. const always = on.indexOf('always') !== -1
  698. if (always) {
  699. on = on.replace('always', '').trim()
  700. }
  701. node.addEventListener(on, function(evt) {
  702. if (node.preloadState === 'PAUSE') {
  703. node.preloadState = 'READY'
  704. if (on === 'mouseover') {
  705. window.setTimeout(load(node), 100)
  706. } else {
  707. load(node)()
  708. }
  709. }
  710. })
  711. switch (on) {
  712. case 'mouseover':
  713. node.addEventListener('touchstart', load(node))
  714. node.addEventListener('mouseout', function(evt) {
  715. if ((evt.target === node) && (node.preloadState === 'READY')) {
  716. node.preloadState = 'PAUSE'
  717. }
  718. })
  719. break
  720. case 'mousedown':
  721. node.addEventListener('touchstart', load(node))
  722. break
  723. }
  724. node.preloadState = 'PAUSE'
  725. node.preloadAlways = always
  726. htmx.trigger(node, 'preload:init')
  727. }
  728. const parent = event.target || event.detail.elt;
  729. parent.querySelectorAll("[preload]").forEach(function(node) {
  730. init(node)
  731. node.querySelectorAll('a,[hx-get],[data-hx-get]').forEach(init)
  732. })
  733. }
  734. })