main.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  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) {
  77. const dialog = document.querySelector("#link-dialog");
  78. const dialogContent = dialog.querySelector(".content-wrapper");
  79. if (!dialogContent) return;
  80. openDialog("link-dialog", "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. // TODO: make it an extension
  106. // open and close dialog
  107. function openDialog(id, name) {
  108. const dialog = document.getElementById(id);
  109. if (!dialog) return;
  110. dialog.classList.add("open");
  111. if (name) {
  112. dialog.classList.add(name);
  113. }
  114. }
  115. function closeDialog() {
  116. const dialog = document.querySelector(".dialog");
  117. if (!dialog) return;
  118. while (dialog.classList.length > 0) {
  119. dialog.classList.remove(dialog.classList[0]);
  120. }
  121. dialog.classList.add("dialog");
  122. }
  123. window.addEventListener("click", function(event) {
  124. const dialog = document.querySelector(".dialog");
  125. if (dialog && event.target === dialog) {
  126. closeDialog();
  127. }
  128. });
  129. // handle navigation in the table of links
  130. function setLinksLimit(event) {
  131. const buttons = Array.from(document.querySelectorAll('table .nav .limit button'));
  132. const limitInput = document.querySelector('#limit');
  133. if (!limitInput || !buttons || !buttons.length) return;
  134. limitInput.value = event.target.textContent;
  135. buttons.forEach(b => {
  136. b.disabled = b.textContent === event.target.textContent;
  137. });
  138. }
  139. function setLinksSkip(event, action) {
  140. const buttons = Array.from(document.querySelectorAll('table .nav .pagination button'));
  141. const limitElm = document.querySelector('#limit');
  142. const totalElm = document.querySelector('#total');
  143. const skipElm = document.querySelector('#skip');
  144. if (!buttons || !limitElm || !totalElm || !skipElm) return;
  145. const skip = parseInt(skipElm.value);
  146. const limit = parseInt(limitElm.value);
  147. const total = parseInt(totalElm.value);
  148. skipElm.value = action === "next" ? skip + limit : Math.max(skip - limit, 0);
  149. document.querySelectorAll('.pagination .next').forEach(elm => {
  150. elm.disabled = total <= parseInt(skipElm.value) + limit;
  151. });
  152. document.querySelectorAll('.pagination .prev').forEach(elm => {
  153. elm.disabled = parseInt(skipElm.value) <= 0;
  154. });
  155. }
  156. function updateLinksNav() {
  157. const totalElm = document.querySelector('#total');
  158. const skipElm = document.querySelector('#skip');
  159. const limitElm = document.querySelector('#limit');
  160. if (!totalElm || !skipElm || !limitElm) return;
  161. const total = parseInt(totalElm.value);
  162. const skip = parseInt(skipElm.value);
  163. const limit = parseInt(limitElm.value);
  164. document.querySelectorAll('.pagination .next').forEach(elm => {
  165. elm.disabled = total <= skip + limit;
  166. });
  167. document.querySelectorAll('.pagination .prev').forEach(elm => {
  168. elm.disabled = skip <= 0;
  169. });
  170. }
  171. function resetLinkNav() {
  172. const totalElm = document.querySelector('#total');
  173. const skipElm = document.querySelector('#skip');
  174. const limitElm = document.querySelector('#limit');
  175. if (!totalElm || !skipElm || !limitElm) return;
  176. skipElm.value = 0;
  177. limitElm.value = 10;
  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. // create views chart label
  191. function createViewsChartLabel(ctx) {
  192. const period = ctx.dataset.period;
  193. let labels = [];
  194. if (period === "day") {
  195. const nowHour = new Date().getHours();
  196. for (let i = 23; i >= 0; --i) {
  197. let h = nowHour - i;
  198. if (h < 0) h = 24 + h;
  199. labels.push(`${Math.floor(h)}:00`);
  200. }
  201. }
  202. if (period === "week") {
  203. const nowDay = new Date().getDate();
  204. for (let i = 6; i >= 0; --i) {
  205. const date = new Date(new Date().setDate(nowDay - i));
  206. labels.push(`${date.getDate()} ${date.toLocaleString("default",{month:"short"})}`);
  207. }
  208. }
  209. if (period === "month") {
  210. const nowDay = new Date().getDate();
  211. for (let i = 29; i >= 0; --i) {
  212. const date = new Date(new Date().setDate(nowDay - i));
  213. labels.push(`${date.getDate()} ${date.toLocaleString("default",{month:"short"})}`);
  214. }
  215. }
  216. if (period === "year") {
  217. const nowMonth = new Date().getMonth();
  218. for (let i = 11; i >= 0; --i) {
  219. const date = new Date(new Date().setMonth(nowMonth - i));
  220. labels.push(`${date.toLocaleString("default",{month:"short"})} ${date.toLocaleString("default",{year:"numeric"})}`);
  221. }
  222. }
  223. return labels;
  224. }
  225. // create views chart
  226. function createViewsChart() {
  227. const canvases = document.querySelectorAll("canvas.visits");
  228. if (!canvases || !canvases.length) return;
  229. canvases.forEach(ctx => {
  230. const data = JSON.parse(ctx.dataset.data);
  231. const period = ctx.dataset.period;
  232. const labels = createViewsChartLabel(ctx);
  233. const maxTicksLimitX = period === "year" ? 6 : period === "month" ? 15 : 12;
  234. const gradient = ctx.getContext("2d").createLinearGradient(0, 0, 0, 300);
  235. gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");
  236. gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
  237. new Chart(ctx, {
  238. type: "line",
  239. data: {
  240. labels: labels,
  241. datasets: [{
  242. label: "Views",
  243. data,
  244. tension: 0.3,
  245. elements: {
  246. point: {
  247. pointRadius: 0,
  248. pointHoverRadius: 4
  249. }
  250. },
  251. fill: {
  252. target: "start",
  253. },
  254. backgroundColor: gradient,
  255. borderColor: "rgb(179, 157, 219)",
  256. borderWidth: 1,
  257. }]
  258. },
  259. options: {
  260. plugins: {
  261. legend: {
  262. display: false,
  263. },
  264. tooltip: {
  265. backgroundColor: "rgba(255, 255, 255, 0.95)",
  266. titleColor: "#333",
  267. titleFont: { weight: "normal", size: 15 },
  268. bodyFont: { weight: "normal", size: 16 },
  269. bodyColor: "rgb(179, 157, 219)",
  270. padding: 12,
  271. cornerRadius: 2,
  272. borderColor: "rgba(0, 0, 0, 0.1)",
  273. borderWidth: 1,
  274. displayColors: false,
  275. }
  276. },
  277. responsive: true,
  278. interaction: {
  279. intersect: false,
  280. usePointStyle: true,
  281. mode: "index",
  282. },
  283. scales: {
  284. y: {
  285. grace: "10%",
  286. beginAtZero: true,
  287. ticks: {
  288. maxTicksLimit: 5
  289. }
  290. },
  291. x: {
  292. ticks: {
  293. maxTicksLimit: maxTicksLimitX,
  294. }
  295. }
  296. }
  297. }
  298. });
  299. // reset the display: block style that chart.js applies automatically
  300. ctx.style.display = "";
  301. });
  302. }
  303. // beautify browser lables
  304. function beautifyBrowserName(name) {
  305. if (name === "firefox") return "Firefox";
  306. if (name === "chrome") return "Chrome";
  307. if (name === "edge") return "Edge";
  308. if (name === "opera") return "Opera";
  309. if (name === "safari") return "Safari";
  310. if (name === "other") return "Other";
  311. if (name === "ie") return "IE";
  312. return name;
  313. }
  314. // create browsers chart
  315. function createBrowsersChart() {
  316. const canvases = document.querySelectorAll("canvas.browsers");
  317. if (!canvases || !canvases.length) return;
  318. canvases.forEach(ctx => {
  319. const data = JSON.parse(ctx.dataset.data);
  320. const period = ctx.dataset.period;
  321. const gradient = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
  322. const gradientHover = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
  323. gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");
  324. gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
  325. gradientHover.addColorStop(0, "rgba(179, 157, 219, 0.9)");
  326. gradientHover.addColorStop(1, "rgba(179, 157, 219, 0.4)");
  327. new Chart(ctx, {
  328. type: "bar",
  329. data: {
  330. labels: data.map(d => beautifyBrowserName(d.name)),
  331. datasets: [{
  332. label: "Views",
  333. data: data.map(d => d.value),
  334. backgroundColor: gradient,
  335. borderColor: "rgba(179, 157, 219, 1)",
  336. borderWidth: 1,
  337. hoverBackgroundColor: gradientHover,
  338. hoverBorderWidth: 2
  339. }]
  340. },
  341. options: {
  342. indexAxis: "y",
  343. plugins: {
  344. legend: {
  345. display: false,
  346. },
  347. tooltip: {
  348. backgroundColor: "rgba(255, 255, 255, 0.95)",
  349. titleColor: "#333",
  350. titleFont: { weight: "normal", size: 15 },
  351. bodyFont: { weight: "normal", size: 16 },
  352. bodyColor: "rgb(179, 157, 219)",
  353. padding: 12,
  354. cornerRadius: 2,
  355. borderColor: "rgba(0, 0, 0, 0.1)",
  356. borderWidth: 1,
  357. displayColors: false,
  358. }
  359. },
  360. responsive: true,
  361. interaction: {
  362. intersect: false,
  363. mode: "index",
  364. axis: "y"
  365. },
  366. scales: {
  367. x: {
  368. grace: "5%",
  369. beginAtZero: true,
  370. ticks: {
  371. maxTicksLimit: 6,
  372. }
  373. }
  374. }
  375. }
  376. });
  377. // reset the display: block style that chart.js applies automatically
  378. ctx.style.display = "";
  379. });
  380. }
  381. // create referrers chart
  382. function createReferrersChart() {
  383. const canvases = document.querySelectorAll("canvas.referrers");
  384. if (!canvases || !canvases.length) return;
  385. canvases.forEach(ctx => {
  386. const data = JSON.parse(ctx.dataset.data);
  387. const period = ctx.dataset.period;
  388. let max = Array.from(data).sort((a, b) => a.value > b.value ? -1 : 1)[0];
  389. let tooltipEnabled = true;
  390. let hoverBackgroundColor = "rgba(179, 157, 219, 1)";
  391. let hoverBorderWidth = 2;
  392. let borderColor = "rgba(179, 157, 219, 1)";
  393. if (data.length === 0) {
  394. data.push({ name: "No views.", value: 1 });
  395. max = { value: 1000 };
  396. tooltipEnabled = false;
  397. hoverBackgroundColor = "rgba(179, 157, 219, 0.1)";
  398. hoverBorderWidth = 1;
  399. borderColor = "rgba(179, 157, 219, 0.2)";
  400. }
  401. new Chart(ctx, {
  402. type: "doughnut",
  403. data: {
  404. labels: data.map(d => d.name.replace(/\[dot\]/g, ".")),
  405. datasets: [{
  406. label: "Views",
  407. data: data.map(d => d.value),
  408. backgroundColor: data.map(d => `rgba(179, 157, 219, ${Math.max((d.value / max.value) - 0.2, 0.1).toFixed(2)})`),
  409. borderWidth: 1,
  410. borderColor,
  411. hoverBackgroundColor,
  412. hoverBorderWidth,
  413. }]
  414. },
  415. options: {
  416. plugins: {
  417. legend: {
  418. position: "left",
  419. labels: {
  420. boxWidth: 25,
  421. font: { size: 11 }
  422. }
  423. },
  424. tooltip: {
  425. enabled: tooltipEnabled,
  426. backgroundColor: "rgba(255, 255, 255, 0.95)",
  427. titleColor: "#333",
  428. titleFont: { weight: "normal", size: 15 },
  429. bodyFont: { weight: "normal", size: 16 },
  430. bodyColor: "rgb(179, 157, 219)",
  431. padding: 12,
  432. cornerRadius: 2,
  433. borderColor: "rgba(0, 0, 0, 0.1)",
  434. borderWidth: 1,
  435. displayColors: false,
  436. }
  437. },
  438. responsive: false,
  439. }
  440. });
  441. // reset the display: block style that chart.js applies automatically
  442. ctx.style.display = "";
  443. });
  444. }
  445. // beautify browser lables
  446. function beautifyOsName(name) {
  447. if (name === "android") return "Android";
  448. if (name === "ios") return "iOS";
  449. if (name === "linux") return "Linux";
  450. if (name === "macos") return "macOS";
  451. if (name === "windows") return "Windows";
  452. if (name === "other") return "Other";
  453. return name;
  454. }
  455. // create operation systems chart
  456. function createOsChart() {
  457. const canvases = document.querySelectorAll("canvas.os");
  458. if (!canvases || !canvases.length) return;
  459. canvases.forEach(ctx => {
  460. const data = JSON.parse(ctx.dataset.data);
  461. const period = ctx.dataset.period;
  462. const gradient = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
  463. const gradientHover = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
  464. gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");
  465. gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
  466. gradientHover.addColorStop(0, "rgba(179, 157, 219, 0.9)");
  467. gradientHover.addColorStop(1, "rgba(179, 157, 219, 0.4)");
  468. new Chart(ctx, {
  469. type: "bar",
  470. data: {
  471. labels: data.map(d => beautifyOsName(d.name)),
  472. datasets: [{
  473. label: "Views",
  474. data: data.map(d => d.value),
  475. backgroundColor: gradient,
  476. borderColor: "rgba(179, 157, 219, 1)",
  477. borderWidth: 1,
  478. hoverBackgroundColor: gradientHover,
  479. hoverBorderWidth: 2
  480. }]
  481. },
  482. options: {
  483. indexAxis: "y",
  484. plugins: {
  485. legend: {
  486. display: false,
  487. },
  488. tooltip: {
  489. backgroundColor: "rgba(255, 255, 255, 0.95)",
  490. titleColor: "#333",
  491. titleFont: { weight: "normal", size: 15 },
  492. bodyFont: { weight: "normal", size: 16 },
  493. bodyColor: "rgb(179, 157, 219)",
  494. padding: 12,
  495. cornerRadius: 2,
  496. borderColor: "rgba(0, 0, 0, 0.1)",
  497. borderWidth: 1,
  498. displayColors: false,
  499. }
  500. },
  501. responsive: true,
  502. interaction: {
  503. intersect: false,
  504. mode: "index",
  505. axis: "y"
  506. },
  507. scales: {
  508. x: {
  509. grace:"5%",
  510. beginAtZero: true,
  511. ticks: {
  512. maxTicksLimit: 6,
  513. }
  514. }
  515. }
  516. }
  517. });
  518. // reset the display: block style that chart.js applies automatically
  519. ctx.style.display = "";
  520. });
  521. }
  522. // add data to the map
  523. function feedMapData(period) {
  524. const map = document.querySelector("svg.map");
  525. const paths = map.querySelectorAll("path");
  526. if (!map || !paths || !paths.length) return;
  527. let data = JSON.parse(map.dataset[period || "day"]);
  528. if (!data) return;
  529. let max = data.sort((a, b) => a.value > b.value ? -1 : 1)[0];
  530. if (!max) max = { value: 1 }
  531. data = data.reduce((a, c) => ({ ...a, [c.name]: c.value }), {});
  532. for (let i = 0; i < paths.length; ++i) {
  533. const id = paths[i].dataset.id;
  534. const views = data[id] || 0;
  535. paths[i].dataset.views = views;
  536. const colorLevel = Math.ceil((views / max.value) * 6);
  537. const classList = paths[i].classList;
  538. for (let j = 1; j < 7; j++) {
  539. paths[i].classList.remove(`color-${j}`);
  540. }
  541. paths[i].classList.add(`color-${colorLevel}`)
  542. paths[i].dataset.views = views;
  543. }
  544. }
  545. // handle map tooltip hover
  546. function mapTooltipHoverOver() {
  547. const tooltip = document.querySelector("#map-tooltip");
  548. if (!tooltip) return;
  549. if (!event.target.dataset.id) return mapTooltipHoverOut();
  550. if (!tooltip.classList.contains("active")) {
  551. tooltip.classList.add("visible");
  552. }
  553. tooltip.dataset.tooltip = `${event.target.ariaLabel}: ${event.target.dataset.views || 0}`;
  554. const rect = event.target.getBoundingClientRect();
  555. tooltip.style.top = rect.top + (rect.height / 2) + "px";
  556. tooltip.style.left = rect.left + (rect.width / 2) + "px";
  557. event.target.classList.add("active");
  558. }
  559. function mapTooltipHoverOut() {
  560. const tooltip = document.querySelector("#map-tooltip");
  561. const map = document.querySelector("svg.map");
  562. const paths = map.querySelectorAll("path");
  563. if (!tooltip || !map) return;
  564. tooltip.classList.remove("visible");
  565. for (let i = 0; i < paths.length; ++i) {
  566. paths[i].classList.remove("active");
  567. }
  568. }
  569. // create stats charts
  570. function createCharts() {
  571. createViewsChart();
  572. createBrowsersChart();
  573. createReferrersChart();
  574. createOsChart();
  575. feedMapData();
  576. }
  577. // change stats period for showing charts and data
  578. function changeStatsPeriod(event) {
  579. const period = event.target.dataset.period;
  580. if (!period) return;
  581. const canvases = document.querySelector("#stats").querySelectorAll("[data-period]");
  582. const buttons = document.querySelector("#stats").querySelectorAll(".nav");
  583. if (!buttons || !canvases) return;
  584. buttons.forEach(b => b.disabled = false);
  585. event.target.disabled = true;
  586. canvases.forEach(canvas => {
  587. if (canvas.dataset.period === period) {
  588. canvas.classList.remove("hidden");
  589. } else {
  590. canvas.classList.add("hidden");
  591. }
  592. });
  593. feedMapData(period);
  594. }