Quellcode durchsuchen

htmx almost done

Pouria Ezzati vor 1 Jahr
Ursprung
Commit
dbc14c8fb6
87 geänderte Dateien mit 2357 neuen und 907 gelöschten Zeilen
  1. 3 3
      docs/api/api.ts
  2. 65 4
      package-lock.json
  3. 2 1
      package.json
  4. 40 48
      server/handlers/auth.handler.js
  5. 0 4
      server/handlers/domains.handler.js
  6. 2 19
      server/handlers/helpers.handler.js
  7. 197 180
      server/handlers/links.handler.js
  8. 6 6
      server/handlers/locals.handler.js
  9. 81 35
      server/handlers/renders.handler.js
  10. 0 2
      server/handlers/users.handler.js
  11. 42 42
      server/handlers/validators.handler.js
  12. 15 0
      server/mail/mail.js
  13. 2 2
      server/queries/index.js
  14. 180 0
      server/queries/visit.queries.js
  15. 0 243
      server/queries/visit.queries.ts
  16. 5 0
      server/queues/index.js
  17. 0 7
      server/queues/index.ts
  18. 75 0
      server/queues/queues.js
  19. 0 20
      server/queues/queues.ts
  20. 24 14
      server/queues/visit.js
  21. 0 1
      server/renders/index.js
  22. 0 27
      server/renders/renders.helper.js
  23. 8 1
      server/routes/auth.routes.js
  24. 2 2
      server/routes/health.routes.js
  25. 24 19
      server/routes/link.routes.js
  26. 65 1
      server/routes/renders.routes.js
  27. 15 9
      server/routes/routes.js
  28. 18 33
      server/server.js
  29. 48 0
      server/utils/map.json
  30. 17 12
      server/utils/utils.js
  31. 10 0
      server/views/404.hbs
  32. 14 0
      server/views/banned.hbs
  33. 0 1
      server/views/homepage.hbs
  34. 1 1
      server/views/layout.hbs
  35. 6 5
      server/views/partials/auth/form.hbs
  36. 4 4
      server/views/partials/features.hbs
  37. 1 0
      server/views/partials/icons/arrow_left.hbs
  38. 1 0
      server/views/partials/icons/chevron_left.hbs
  39. 1 0
      server/views/partials/icons/chevron_right.hbs
  40. 1 0
      server/views/partials/icons/eye.hbs
  41. 1 0
      server/views/partials/icons/heart.hbs
  42. 1 0
      server/views/partials/icons/login.hbs
  43. 1 0
      server/views/partials/icons/new_user.hbs
  44. 1 0
      server/views/partials/icons/send.hbs
  45. 1 0
      server/views/partials/icons/shuffle.hbs
  46. 1 0
      server/views/partials/icons/write.hbs
  47. 1 0
      server/views/partials/icons/x.hbs
  48. 7 2
      server/views/partials/links/actions.hbs
  49. 2 1
      server/views/partials/links/dialog/ban.hbs
  50. 2 2
      server/views/partials/links/dialog/ban_success.hbs
  51. 4 3
      server/views/partials/links/dialog/delete.hbs
  52. 2 2
      server/views/partials/links/dialog/delete_success.hbs
  53. 1 1
      server/views/partials/links/dialog/frame.hbs
  54. 1 1
      server/views/partials/links/dialog/message.hbs
  55. 2 1
      server/views/partials/links/edit.hbs
  56. 1 1
      server/views/partials/links/loading.hbs
  57. 8 8
      server/views/partials/links/nav.hbs
  58. 1 1
      server/views/partials/links/table.hbs
  59. 6 4
      server/views/partials/links/thead.hbs
  60. 33 0
      server/views/partials/protected/form.hbs
  61. 17 0
      server/views/partials/report/email.hbs
  62. 30 0
      server/views/partials/report/form.hbs
  63. 30 0
      server/views/partials/reset_password/form.hbs
  64. 2 1
      server/views/partials/settings/apikey.hbs
  65. 1 1
      server/views/partials/settings/change_email.hbs
  66. 1 1
      server/views/partials/settings/change_password.hbs
  67. 1 1
      server/views/partials/settings/delete_account.hbs
  68. 2 1
      server/views/partials/settings/domain/delete.hbs
  69. 2 2
      server/views/partials/settings/domain/delete_success.hbs
  70. 1 1
      server/views/partials/settings/domain/dialog.hbs
  71. 1 0
      server/views/partials/settings/domain/index.hbs
  72. 1 0
      server/views/partials/settings/domain/table.hbs
  73. 3 2
      server/views/partials/shortener.hbs
  74. 100 0
      server/views/partials/stats.hbs
  75. 11 0
      server/views/protected.hbs
  76. 13 0
      server/views/report.hbs
  77. 12 0
      server/views/reset_password.hbs
  78. 15 0
      server/views/reset_password_result.hbs
  79. 1 1
      server/views/settings.hbs
  80. 24 0
      server/views/stats.hbs
  81. 50 0
      server/views/terms.hbs
  82. 6 0
      server/views/url_info.hbs
  83. 15 0
      server/views/verify.hbs
  84. 19 0
      server/views/verify_change_email.hbs
  85. 457 123
      static/css/styles.css
  86. 12 0
      static/libs/chart.min.js
  87. 478 0
      static/scripts/main.js

+ 3 - 3
docs/api/api.ts

@@ -444,9 +444,6 @@ export default {
       Stats: {
         type: "object",
         properties: {
-          allTime: {
-            $ref: "#/components/schemas/StatsItem"
-          },
           lastDay: {
             $ref: "#/components/schemas/StatsItem"
           },
@@ -456,6 +453,9 @@ export default {
           lastWeek: {
             $ref: "#/components/schemas/StatsItem"
           },
+          lastYear: {
+            $ref: "#/components/schemas/StatsItem"
+          },
           updatedAt: {
             type: "string"
           },

+ 65 - 4
package-lock.json

@@ -12,7 +12,8 @@
         "app-root-path": "^3.1.0",
         "axios": "^1.1.3",
         "bcryptjs": "^2.4.3",
-        "bull": "^4.10.1",
+        "bull": "^4.16.2",
+        "compression": "^1.7.4",
         "cookie-parser": "^1.4.6",
         "cors": "^2.8.5",
         "cross-env": "^7.0.3",
@@ -3027,9 +3028,9 @@
       "license": "MIT"
     },
     "node_modules/bull": {
-      "version": "4.16.0",
-      "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.0.tgz",
-      "integrity": "sha512-dgHRLULPexLkpm9wP/7F7Vlf2fdvmffdwhv3Bqu5lFhO+XDDJ4yGqlTPE61Jj1zM8CgchLmJEgIfe7y69jtuOg==",
+      "version": "4.16.2",
+      "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.2.tgz",
+      "integrity": "sha512-VCy33UdPGiIoZHDTrslGXKXWxcIUHNH5Z82pihr8HicbIfAH4SHug1HxlwKEbibVv85hq8rJ9tKAW/cuxv2T0A==",
       "license": "MIT",
       "dependencies": {
         "cron-parser": "^4.2.1",
@@ -3410,6 +3411,66 @@
         "node": ">=14"
       }
     },
+    "node_modules/compressible": {
+      "version": "2.0.18",
+      "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+      "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": ">= 1.43.0 < 2"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/compression": {
+      "version": "1.7.4",
+      "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz",
+      "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.5",
+        "bytes": "3.0.0",
+        "compressible": "~2.0.16",
+        "debug": "2.6.9",
+        "on-headers": "~1.0.2",
+        "safe-buffer": "5.1.2",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/compression/node_modules/bytes": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
+      "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/compression/node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/compression/node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/compression/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
     "node_modules/concat-map": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",

+ 2 - 1
package.json

@@ -32,7 +32,8 @@
     "app-root-path": "^3.1.0",
     "axios": "^1.1.3",
     "bcryptjs": "^2.4.3",
-    "bull": "^4.10.1",
+    "bull": "^4.16.2",
+    "compression": "^1.7.4",
     "cookie-parser": "^1.4.6",
     "cors": "^2.8.5",
     "cross-env": "^7.0.3",

+ 40 - 48
server/handlers/auth.handler.js

@@ -54,9 +54,6 @@ const jwt = authenticate("jwt", "Unauthorized.", true);
 const jwtLoose = authenticate("jwt", "Unauthorized.", false);
 const apikey = authenticate("localapikey", "API key is not correct.", false);
 
-/**
- * @type {import("express").Handler}
- */
 async function cooldown(req, res, next) {
   if (env.DISALLOW_ANONYMOUS_LINKS) return next();
   const cooldownConfig = env.NON_USER_COOLDOWN;
@@ -78,18 +75,12 @@ async function cooldown(req, res, next) {
   next();
 }
 
-/**
- * @type {import("express").Handler}
- */
 function admin(req, res, next) {
   // FIXME: attaching to req is risky, find another way
   if (req.user.admin) return next();
   throw new CustomError("Unauthorized", 401);
 }
 
-/**
- * @type {import("express").Handler}
- */
 async function signup(req, res) {
   const salt = await bcrypt.genSalt(12);
   const password = await bcrypt.hash(req.body.password, salt);
@@ -109,9 +100,6 @@ async function signup(req, res) {
   return res.status(201).send({ message: "A verification email has been sent." });
 }
 
-/**
- * @type {import("express").Handler}
- */
 function login(req, res) {
   const token = utils.signToken(req.user);
 
@@ -128,9 +116,6 @@ function login(req, res) {
   return res.status(200).send({ token });
 }
 
-/**
- * @type {import("express").Handler}
- */
 async function verify(req, res, next) {
   if (!req.params.verificationToken) return next();
   
@@ -148,15 +133,19 @@ async function verify(req, res, next) {
   
   if (user) {
     const token = utils.signToken(user);
-    req.token = token;
+    res.clearCookie("token", { httpOnly: true, secure: env.isProd });
+    res.cookie("token", token, {
+      maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
+      httpOnly: true,
+      secure: env.isProd
+    });
+    res.locals.token_verified = true;
+    req.cookies.token = token;
   }
   
   return next();
 }
 
-/**
- * @type {import("express").Handler}
- */
 async function changePassword(req, res) {
   const isMatch = await bcrypt.compare(req.body.currentpassword, req.user.password);
   if (!isMatch) {
@@ -174,8 +163,6 @@ async function changePassword(req, res) {
     throw new CustomError("Couldn't change the password. Try again later.");
   }
 
-  await utils.sleep(1000);
-
   if (req.isHTML) {
     res.setHeader("HX-Trigger-After-Swap", "resetChangePasswordForm");
     res.render("partials/settings/change_password", {
@@ -189,9 +176,6 @@ async function changePassword(req, res) {
     .send({ message: "Your password has been changed successfully." });
 }
 
-/**
- * @type {import("express").Handler}
- */
 async function generateApiKey(req, res) {
   const apikey = nanoid(40);
   
@@ -203,8 +187,6 @@ async function generateApiKey(req, res) {
     throw new CustomError("Couldn't generate API key. Please try again later.");
   }
 
-  await utils.sleep(1000);
-
   if (req.isHTML) {
     res.render("partials/settings/apikey", {
       user: { apikey },
@@ -215,9 +197,6 @@ async function generateApiKey(req, res) {
   return res.status(201).send({ apikey });
 }
 
-/**
- * @type {import("express").Handler}
- */
 async function resetPasswordRequest(req, res) {
   const [user] = await query.user.update(
     { email: req.body.email },
@@ -228,7 +207,15 @@ async function resetPasswordRequest(req, res) {
   );
   
   if (user) {
-    await mail.resetPasswordToken(user);
+    // TODO: handle error
+    await mail.resetPasswordToken(user).catch(() => null);
+  }
+
+  if (req.isHTML) {
+    res.render("partials/reset_password/form", {
+      message: "If the email address exists, a reset password email will be sent to it."
+    });
+    return;
   }
   
   return res.status(200).send({
@@ -236,12 +223,9 @@ async function resetPasswordRequest(req, res) {
   });
 }
 
-/**
- * @type {import("express").Handler}
- */
 async function resetPassword(req, res, next) {
-  const { resetPasswordToken } = req.params;
-  
+  const resetPasswordToken = req.params.resetPasswordToken;
+
   if (resetPasswordToken) {
     const [user] = await query.user.update(
       {
@@ -253,23 +237,25 @@ async function resetPassword(req, res, next) {
   
     if (user) {
       const token = utils.signToken(user);
-      req.token = token;
+      res.clearCookie("token", { httpOnly: true, secure: env.isProd });
+      res.cookie("token", token, {
+        maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
+        httpOnly: true,
+        secure: env.isProd
+      });
+      res.locals.token_verified = true;
+      req.cookies.token = token;
     }
   }
-  return next();
+
+  next();
 }
 
-/**
- * @type {import("express").Handler}
- */
 function signupAccess(req, res, next) {
   if (!env.DISALLOW_REGISTRATION) return next();
   return res.status(403).send({ message: "Registration is not allowed." });
 }
 
-/**
- * @type {import("express").Handler}
- */
 async function changeEmailRequest(req, res) {
   const { email, password } = req.body;
   
@@ -317,11 +303,10 @@ async function changeEmailRequest(req, res) {
   return res.status(200).send({ message });
 }
 
-/**
- * @type {import("express").Handler}
- */
 async function changeEmail(req, res, next) {
-  const { changeEmailToken } = req.params;
+  const changeEmailToken = req.params.changeEmailToken;
+
+  console.log("-", changeEmailToken, "-");
   
   if (changeEmailToken) {
     const foundUser = await query.user.find({
@@ -347,7 +332,14 @@ async function changeEmail(req, res, next) {
   
     if (user) {
       const token = utils.signToken(user);
-      req.token = token;
+      res.clearCookie("token", { httpOnly: true, secure: env.isProd });
+      res.cookie("token", token, {
+        maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
+        httpOnly: true,
+        secure: env.isProd
+      });
+      res.locals.token_verified = true;
+      req.cookies.token = token;
     }
   }
   return next();

+ 0 - 4
server/handlers/domains.handler.js

@@ -13,8 +13,6 @@ async function add(req, res) {
     user_id: req.user.id
   });
 
-  await sleep(1000);
-
   if (req.isHTML) {
     const domains = (await query.domain.get({ user_id: req.user.id })).map(sanitize.domain);
     res.setHeader("HX-Reswap", "none");
@@ -38,8 +36,6 @@ async function remove(req, res) {
 
   redis.remove.domain(domain);
 
-  await sleep(1000);
-
   if (!domain) {
     throw new CustomError("Could not delete the domain.", 500);
   }

+ 2 - 19
server/handlers/helpers.handler.js

@@ -12,9 +12,6 @@ const env = require("../env");
 //   return next();
 // };
 
-/**
- * @type {import("express").Handler}
- */
 function isHTML(req, res, next) {
   const accepts = req.accepts(["json", "html"]);
   req.isHTML = accepts === "html";
@@ -22,9 +19,6 @@ function isHTML(req, res, next) {
 }
 
 function addNoLayoutLocals(req, res, next) {
-/**
- * @type {import("express").Handler}
- */
   res.locals.layout = null;
   next();
 }
@@ -36,17 +30,12 @@ function viewTemplate(template) {
   }
 }
 
-/**
- * @type {import("express").Handler}
- */
 function addConfigLocals(req, res, next) {
   res.locals.default_domain = env.DEFAULT_DOMAIN;
+  res.locals.site_name = env.SITE_NAME;
   next();
 }
 
-/**
- * @type {import("express").Handler}
- */
 async function addUserLocals(req, res, next) {
   const user = req.user;
   res.locals.user = user;
@@ -54,9 +43,6 @@ async function addUserLocals(req, res, next) {
   next();
 }
 
-/**
- * @type {import("express").ErrorRequestHandler}
- */
 function error(error, req, res, _next) {
   if (env.isDev) {
     signale.fatal(error);
@@ -74,9 +60,6 @@ function error(error, req, res, _next) {
 };
 
 
-/**
- * @type {import("express").Handler}
- */
 function verify(req, res, next) {
   const result = validationResult(req);
   if (result.isEmpty()) return next();
@@ -124,7 +107,7 @@ function parseQuery(req, res, next) {
   req.context = {
     limit: limit > 50 ? 50 : limit,
     skip,
-    all: admin ? req.query.all === "true" : false
+    all: admin ? req.query.all === "true" || req.query.all === "on" : false
   };
 
   next();

+ 197 - 180
server/handlers/links.handler.js

@@ -5,9 +5,10 @@ const URL = require("url");
 const dns = require("dns");
 
 const validators = require("./validators.handler");
-// const transporter = require("../mail");
+const map = require("../utils/map.json");
+const transporter = require("../mail");
 const query = require("../queries");
-// const queue = require("../queues");
+const queue = require("../queues");
 const utils = require("../utils");
 const env = require("../env");
 const { differenceInSeconds } = require("date-fns");
@@ -15,9 +16,6 @@ const { differenceInSeconds } = require("date-fns");
 const CustomError = utils.CustomError;
 const dnsLookup = promisify(dns.lookup);
 
-/**
- * @type {import("express").Handler}
- */
 async function get(req, res) {
   const { limit, skip, all } = req.context;
   const search = req.query.search;
@@ -34,8 +32,6 @@ async function get(req, res) {
 
   const links = data.map(utils.sanitize.link);
 
-  await utils.sleep(1000);
-    
   if (req.isHTML) {
     res.render("partials/links/table", {
       total,
@@ -54,9 +50,6 @@ async function get(req, res) {
   });
 };
 
-/**
- * @type {import("express").Handler}
- */
 async function create(req, res) {
   const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body;
   const domain_id = fetched_domain ? fetched_domain.id : null;
@@ -78,7 +71,7 @@ async function create(req, res) {
         address: customurl,
         domain_id
       }),
-    !customurl && utils.generateId(domain_id),
+    !customurl && utils.generateId(query, domain_id),
     validators.bannedDomain(targetDomain),
     validators.bannedHost(targetDomain)
   ]);
@@ -161,8 +154,6 @@ async function edit(req, res) {
     isChanged = true;
   });
 
-  await utils.sleep(1000);
-  
   if (!isChanged) {
     throw new CustomError("Should at least update one field.");
   }
@@ -215,9 +206,6 @@ async function edit(req, res) {
   return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
 };
 
-/**
- * @type {import("express").Handler}
- */
 async function remove(req, res) {
   const { error, isRemoved, link } = await query.link.remove({
     uuid: req.params.id,
@@ -229,8 +217,6 @@ async function remove(req, res) {
     throw new CustomError(messsage);
   }
 
-  await utils.sleep(1000);
-
   if (req.isHTML) {
     res.setHeader("HX-Reswap", "outerHTML");
     res.setHeader("HX-Trigger", "reloadLinks");
@@ -245,24 +231,22 @@ async function remove(req, res) {
     .send({ message: "Link has been deleted successfully." });
 };
 
-// export const report: Handler = async (req, res) => {
-//   const { link } = req.body;
-
-//   const mail = await transporter.sendMail({
-//     from: env.MAIL_FROM || env.MAIL_USER,
-//     to: env.REPORT_EMAIL,
-//     subject: "[REPORT]",
-//     text: link,
-//     html: link
-//   });
-
-//   if (!mail.accepted.length) {
-//     throw new CustomError("Couldn't submit the report. Try again later.");
-//   }
-//   return res
-//     .status(200)
-//     .send({ message: "Thanks for the report, we'll take actions shortly." });
-// };
+async function report(req, res) {
+  const { link } = req.body;
+
+  await transporter.sendReportEmail(link);
+
+  if (req.isHTML) {
+    res.render("partials/report/form", {
+      message: "Report was received. We'll take actions shortly."
+    });
+    return;
+  }
+  
+  return res
+    .status(200)
+    .send({ message: "Thanks for the report, we'll take actions shortly." });
+};
 
 async function ban(req, res) {
   const { id } = req.params;
@@ -320,8 +304,6 @@ async function ban(req, res) {
   });
 
   // 8. Send response
-  await utils.sleep(1000);
-  
   if (req.isHTML) {
     res.setHeader("HX-Reswap", "outerHTML");
     res.setHeader("HX-Trigger", "reloadLinks");
@@ -334,148 +316,178 @@ async function ban(req, res) {
   return res.status(200).send({ message: "Banned link successfully." });
 };
 
-// export const redirect = (app) => async (
-//   req,
-//   res,
-//   next
-// ) => {
-//   const isBot = isbot(req.headers["user-agent"]);
-//   const isPreservedUrl = validators.preservedUrls.some(
-//     item => item === req.path.replace("/", "")
-//   );
-
-//   if (isPreservedUrl) return next();
-
-//   // 1. If custom domain, get domain info
-//   const host = utils.removeWww(req.headers.host);
-//   const domain =
-//     host !== env.DEFAULT_DOMAIN
-//       ? await query.domain.find({ address: host })
-//       : null;
-
-//   // 2. Get link
-//   const address = req.params.id.replace("+", "");
-//   const link = await query.link.find({
-//     address,
-//     domain_id: domain ? domain.id : null
-//   });
-
-//   // 3. When no link, if has domain redirect to domain's homepage
-//   // otherwise redirect to 404
-//   if (!link) {
-//     return res.redirect(302, domain ? domain.homepage : "/404");
-//   }
-
-//   // 4. If link is banned, redirect to banned page.
-//   if (link.banned) {
-//     return res.redirect("/banned");
-//   }
-
-//   // 5. If wants to see link info, then redirect
-//   const doesRequestInfo = /.*\+$/gi.test(req.params.id);
-//   if (doesRequestInfo && !link.password) {
-//     return app.render(req, res, "/url-info", { target: link.target });
-//   }
-
-//   // 6. If link is protected, redirect to password page
-//   if (link.password) {
-//     return res.redirect(`/protected/${link.uuid}`);
-//   }
-
-//   // 7. Create link visit
-//   if (link.user_id && !isBot) {
-//     queue.visit.add({
-//       headers: req.headers,
-//       realIP: req.realIP,
-//       referrer: req.get("Referrer"),
-//       link
-//     });
-//   }
-
-//   // 8. Redirect to target
-//   return res.redirect(link.target);
-// };
-
-// export const redirectProtected: Handler = async (req, res) => {
-//   // 1. Get link
-//   const uuid = req.params.id;
-//   const link = await query.link.find({ uuid });
-
-//   // 2. Throw error if no link
-//   if (!link || !link.password) {
-//     throw new CustomError("Couldn't find the link.", 400);
-//   }
-
-//   // 3. Check if password matches
-//   const matches = await bcrypt.compare(req.body.password, link.password);
-
-//   if (!matches) {
-//     throw new CustomError("Password is not correct.", 401);
-//   }
-
-//   // 4. Create visit
-//   if (link.user_id) {
-//     queue.visit.add({
-//       headers: req.headers,
-//       realIP: req.realIP,
-//       referrer: req.get("Referrer"),
-//       link
-//     });
-//   }
-
-//   // 5. Send target
-//   return res.status(200).send({ target: link.target });
-// };
-
-// export const redirectCustomDomain: Handler = async (req, res, next) => {
-//   const { path } = req;
-//   const host = utils.removeWww(req.headers.host);
-
-//   if (host === env.DEFAULT_DOMAIN) {
-//     return next();
-//   }
-
-//   if (
-//     path === "/" ||
-//     validators.preservedUrls
-//       .filter(l => l !== "url-password")
-//       .some(item => item === path.replace("/", ""))
-//   ) {
-//     const domain = await query.domain.find({ address: host });
-//     const redirectURL = domain
-//       ? domain.homepage
-//       : `https://${env.DEFAULT_DOMAIN + path}`;
-
-//     return res.redirect(302, redirectURL);
-//   }
-
-//   return next();
-// };
-
-// export const stats: Handler = async (req, res) => {
-//   const { user } = req;
-//   const uuid = req.params.id;
-
-//   const link = await query.link.find({
-//     ...(!user.admin && { user_id: user.id }),
-//     uuid
-//   });
-
-//   if (!link) {
-//     throw new CustomError("Link could not be found.");
-//   }
-
-//   const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
-
-//   if (!stats) {
-//     throw new CustomError("Could not get the short link stats.");
-//   }
-
-//   return res.status(200).send({
-//     ...stats,
-//     ...utils.sanitize.link(link)
-//   });
-// };
+async function redirect(req, res, next) {
+  const isPreservedUrl = utils.preservedURLs.some(
+    item => item === req.path.replace("/", "")
+  );
+
+  if (isPreservedUrl) return next();
+
+  // 1. If custom domain, get domain info
+  const host = utils.removeWww(req.headers.host);
+  const domain =
+    host !== env.DEFAULT_DOMAIN
+      ? await query.domain.find({ address: host })
+      : null;
+
+  // 2. Get link
+  const address = req.params.id.replace("+", "");
+  const link = await query.link.find({
+    address,
+    domain_id: domain ? domain.id : null
+  });
+
+  // 3. When no link, if has domain redirect to domain's homepage
+  // otherwise redirect to 404
+  if (!link) {
+    return res.redirect(domain.homepage || "/404");
+  }
+
+  // 4. If link is banned, redirect to banned page.
+  if (link.banned) {
+    return res.redirect("/banned");
+  }
+
+  // 5. If wants to see link info, then redirect
+  const isRequestingInfo = /.*\+$/gi.test(req.params.id);
+  if (isRequestingInfo && !link.password) {
+    if (req.isHTML) {
+      res.render("url_info", { 
+        title: "Short link information",
+        target: link.target,
+        link: utils.getShortURL(link.address, link.domain).link
+      });
+      return;
+    }
+    return res.send({ target: link.target });
+  }
+
+  // 6. If link is protected, redirect to password page
+  if (link.password) {
+    res.render("protected", {
+      title: "Protected short link",
+      id: link.uuid
+    });
+    return;
+  }
+
+  // 7. Create link visit
+  const isBot = isbot(req.headers["user-agent"]);
+  if (link.user_id && !isBot) {
+    queue.visit.add({
+      headers: req.headers,
+      realIP: req.realIP,
+      referrer: req.get("Referrer"),
+      link
+    });
+  }
+
+  // 8. Redirect to target
+  return res.redirect(link.target);
+};
+
+async function redirectProtected(req, res) {
+  // 1. Get link
+  const uuid = req.params.id;
+  const link = await query.link.find({ uuid });
+
+  // 2. Throw error if no link
+  if (!link || !link.password) {
+    throw new CustomError("Couldn't find the link.", 400);
+  }
+
+  // 3. Check if password matches
+  const matches = await bcrypt.compare(req.body.password, link.password);
+
+  if (!matches) {
+    throw new CustomError("Password is not correct.", 401);
+  }
+
+  // 4. Create visit
+  if (link.user_id) {
+    queue.visit.add({
+      headers: req.headers,
+      realIP: req.realIP,
+      referrer: req.get("Referrer"),
+      link
+    });
+  }
+
+  // 5. Send target
+  if (req.isHTML) {
+    res.setHeader("HX-Redirect", link.target);
+    res.render("partials/protected/form", {
+      id: link.uuid,
+      message: "Redirecting...",
+    });
+    return;
+  }
+  return res.status(200).send({ target: link.target });
+};
+
+async function redirectCustomDomainHomepage(req, res, next) {
+  const path = req.path;
+  const host = utils.removeWww(req.headers.host);
+
+  if (host === env.DEFAULT_DOMAIN) {
+    return next();
+  }
+
+  if (
+    path === "/" ||
+    utils.preservedURLs
+      .filter(l => l !== "url-password")
+      .some(item => item === path.replace("/", ""))
+  ) {
+    const domain = await query.domain.find({ address: host });
+    const redirectURL = domain
+      ? domain.homepage
+      : `https://${env.DEFAULT_DOMAIN + path}`;
+
+    return res.redirect(302, redirectURL);
+  }
+
+  return next();
+};
+
+async function stats(req, res) {
+  const { user } = req;
+  const uuid = req.params.id;
+
+  const link = await query.link.find({
+    ...(!user.admin && { user_id: user.id }),
+    uuid
+  });
+
+  if (!link) {
+    if (req.isHTML) {
+      res.setHeader("HX-Redirect", "/404");
+      res.status(200).send("");
+      return;
+    }
+    throw new CustomError("Link could not be found.");
+  }
+
+  const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
+
+  if (!stats) {
+    throw new CustomError("Could not get the short link stats. Try again later.");
+  }
+
+  if (req.isHTML) {
+    res.render("partials/stats", {
+      link: utils.sanitize.link(link),
+      stats,
+      map,
+    });
+    return;
+  }
+
+  return res.status(200).send({
+    ...stats,
+    ...utils.sanitize.link(link)
+  });
+};
 
 module.exports = {
   ban,
@@ -483,4 +495,9 @@ module.exports = {
   edit,
   get,
   remove,
+  report,
+  stats,
+  redirect,
+  redirectProtected,
+  redirectCustomDomainHomepage,
 }

+ 6 - 6
server/handlers/locals.handler.js

@@ -1,21 +1,21 @@
-/**
- * @type {import("express").Handler}
- */
 function createLink(req, res, next) {
   res.locals.show_advanced = !!req.body.show_advanced;
   next();
 }
 
-/**
- * @type {import("express").Handler}
- */
 function editLink(req, res, next) {
   res.locals.id = req.params.id;
   res.locals.class = "no-animation";
   next();
 }
 
+function protected(req, res, next) {
+  res.locals.id = req.params.id;
+  next();
+}
+
 module.exports = {
   createLink,
   editLink,
+  protected,
 }

+ 81 - 35
server/renders/renders.handler.js → server/handlers/renders.handler.js

@@ -2,18 +2,12 @@ const utils = require("../utils");
 const query = require("../queries")
 const env = require("../env");
 
-/**
- * @type {import("express").Handler}
- */
 async function homepage(req, res) {
   res.render("homepage", {
     title: "Modern open source URL shortener",
   });
 }
 
-/**
- * @type {import("express").Handler}
- */
 function login(req, res) {
   if (req.user) {
     return res.redirect("/");
@@ -23,9 +17,6 @@ function login(req, res) {
   });
 }
 
-/**
- * @type {import("express").Handler}
- */
 function logout(req, res) {
   res.clearCookie("token", { httpOnly: true, secure: env.isProd });
   res.render("logout", {
@@ -33,9 +24,12 @@ function logout(req, res) {
   });
 }
 
-/**
- * @type {import("express").Handler}
- */
+function notFound(req, res) {
+  res.render("404", {
+    title: "404 - Not found"
+  });
+}
+
 function settings(req, res) {
   // TODO: make this a middelware function, apply it to where it's necessary
   if (!req.user) {
@@ -46,16 +40,64 @@ function settings(req, res) {
   });
 }
 
+function stats(req, res) {
+  // TODO: make this a middelware function, apply it to where it's necessary
+  if (!req.user) {
+    return res.redirect("/");
+  }
+  const id = req.query.id;
+  res.render("stats", {
+    title: "Stats"
+  });
+}
+
+async function banned(req, res) {
+  res.render("banned", {
+    title: "Banned link",
+  });
+}
+
+async function report(req, res) {
+  res.render("report", {
+    title: "Report abuse",
+  });
+}
+
+async function resetPassword(req, res) {
+  res.render("reset_password", {
+    title: "Reset password",
+  });
+}
+
+async function resetPasswordResult(req, res) {
+  res.render("reset_password_result", {
+    title: "Reset password",
+  });
+}
+
+async function verifyChangeEmail(req, res) {
+  res.render("verify_change_email", {
+    title: "Verifying email",
+  });
+}
+
+async function verify(req, res) {
+  res.render("verify", {
+    title: "Verify",
+  });
+}
+
+async function terms(req, res) {
+  res.render("terms", {
+    title: "Terms of Service",
+  });
+}
 
-/**
- * @type {import("express").Handler}
- */
 async function confirmLinkDelete(req, res) {
   const link = await query.link.find({
     uuid: req.query.id,
     ...(!req.user.admin && { user_id: req.user.id })
   });
-  await utils.sleep(500);
   if (!link) {
     return res.render("partials/links/dialog/message", {
       layout: false,
@@ -69,15 +111,11 @@ async function confirmLinkDelete(req, res) {
   });
 }
 
-/**
- * @type {import("express").Handler}
- */
 async function confirmLinkBan(req, res) {
   const link = await query.link.find({
     uuid: req.query.id,
     ...(!req.user.admin && { user_id: req.user.id })
   });
-  await utils.sleep(500);
   if (!link) {
     return res.render("partials/links/dialog/message", {
       message: "Could not find the link."
@@ -89,23 +127,15 @@ async function confirmLinkBan(req, res) {
   });
 }
 
-/**
- * @type {import("express").Handler}
- */
 async function addDomainForm(req, res) {
-  await utils.sleep(1000);
   res.render("partials/settings/domain/add_form");
 }
 
-/**
- * @type {import("express").Handler}
- */
 async function confirmDomainDelete(req, res) {
   const domain = await query.domain.find({
     uuid: req.query.id,
     user_id: req.user.id
   });
-  await utils.sleep(500);
   if (!domain) {
     throw new utils.CustomError("Could not find the link", 400);
   }
@@ -115,15 +145,21 @@ async function confirmDomainDelete(req, res) {
 }
 
 
-/**
- * @type {import("express").Handler}
- */
+async function getReportEmail(req, res) {
+  if (!env.REPORT_EMAIL) {
+    throw new utils.CustomError("No report email is available.", 400);
+  }
+  res.render("partials/report/email", {
+    report_email: env.REPORT_EMAIL.replace("@", "[at]")
+  });
+}
+
+
 async function linkEdit(req, res) {
   const link = await query.link.find({
     uuid: req.params.id,
     ...(!req.user.admin && { user_id: req.user.id })
   });
-  await utils.sleep(500);
   // TODO: handle when no link
   // if (!link) {
   //   return res.render("partials/links/dialog/message", {
@@ -138,12 +174,22 @@ async function linkEdit(req, res) {
 
 module.exports = {
   addDomainForm,
+  banned,
+  confirmDomainDelete,
+  confirmLinkBan,
+  confirmLinkDelete,
+  getReportEmail,
   homepage,
   linkEdit,
   login,
   logout,
-  confirmDomainDelete,
-  confirmLinkBan,
-  confirmLinkDelete,
+  notFound,
+  report,
+  resetPassword,
+  resetPasswordResult,
   settings,
+  stats,
+  terms,
+  verifyChangeEmail,
+  verify,
 }

+ 0 - 2
server/handlers/users.handler.js

@@ -17,8 +17,6 @@ async function get(req, res) {
 async function remove(req, res) {
   await query.user.remove(req.user);
 
-  await utils.sleep(1000);
-
   if (req.isHTML) {
     res.clearCookie("token", { httpOnly: true, secure: env.isProd });
     res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage");

+ 42 - 42
server/handlers/validators.handler.js

@@ -167,16 +167,16 @@ const editLink = [
     .isLength({ min: 36, max: 36 })
 ];
 
-// export const redirectProtected = [
-//   body("password", "Password is invalid.")
-//     .exists({ checkFalsy: true, checkNull: true })
-//     .isString()
-//     .isLength({ min: 3, max: 64 })
-//     .withMessage("Password length must be between 3 and 64."),
-//   param("id", "ID is invalid.")
-//     .exists({ checkFalsy: true, checkNull: true })
-//     .isLength({ min: 36, max: 36 })
-// ];
+const redirectProtected = [
+  body("password", "Password is invalid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .isString()
+    .isLength({ min: 3, max: 64 })
+    .withMessage("Password length must be between 3 and 64."),
+  param("id", "ID is invalid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .isLength({ min: 36, max: 36 })
+];
 
 const addDomain = [
   body("address", "Domain is not valid.")
@@ -221,18 +221,18 @@ const deleteLink = [
     .isLength({ min: 36, max: 36 })
 ];
 
-// export const reportLink = [
-//   body("link", "No link has been provided.")
-//     .exists({
-//       checkFalsy: true,
-//       checkNull: true
-//     })
-//     .customSanitizer(addProtocol)
-//     .custom(
-//       value => removeWww(URL.parse(value).hostname) === env.DEFAULT_DOMAIN
-//     )
-//     .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
-// ];
+const reportLink = [
+  body("link", "No link has been provided.")
+    .exists({
+      checkFalsy: true,
+      checkNull: true
+    })
+    .customSanitizer(addProtocol)
+    .custom(
+      value => removeWww(URL.parse(value).host) === env.DEFAULT_DOMAIN
+    )
+    .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
+];
 
 const banLink = [
   param("id", "ID is invalid.")
@@ -267,14 +267,14 @@ const banLink = [
     .isBoolean()
 ];
 
-// export const getStats = [
-//   param("id", "ID is invalid.")
-//     .exists({
-//       checkFalsy: true,
-//       checkNull: true
-//     })
-//     .isLength({ min: 36, max: 36 })
-// ];
+const getStats = [
+  param("id", "ID is invalid.")
+    .exists({
+      checkFalsy: true,
+      checkNull: true
+    })
+    .isLength({ min: 36, max: 36 })
+];
 
 const signup = [
   body("password", "Password is not valid.")
@@ -336,18 +336,14 @@ const changeEmail = [
     .withMessage("Email length must be max 255.")
 ];
 
-// export const resetPasswordRequest = [
-//   body("email", "Email is not valid.")
-//     .exists({ checkFalsy: true, checkNull: true })
-//     .trim()
-//     .isEmail()
-//     .isLength({ min: 0, max: 255 })
-//     .withMessage("Email length must be max 255."),
-//   body("password", "Password is not valid.")
-//     .exists({ checkFalsy: true, checkNull: true })
-//     .isLength({ min: 8, max: 64 })
-//     .withMessage("Password length must be between 8 and 64.")
-// ];
+const resetPassword = [
+  body("email", "Email is not valid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .trim()
+    .isEmail()
+    .isLength({ min: 0, max: 255 })
+    .withMessage("Email length must be max 255.")
+];
 
 // export const resetEmailRequest = [
 //   body("email", "Email is not valid.")
@@ -496,9 +492,13 @@ module.exports = {
   deleteLink,
   deleteUser,
   editLink,
+  getStats,
   linksCount,
   login, 
   malware,
+  redirectProtected,
   removeDomain,
+  reportLink,
+  resetPassword,
   signup,
 }

+ 15 - 0
server/mail/mail.js

@@ -100,8 +100,23 @@ async function resetPasswordToken(user) {
   }
 }
 
+async function sendReportEmail(link) {
+  const mail = await transporter.sendMail({
+    from: env.MAIL_FROM || env.MAIL_USER,
+    to: env.REPORT_EMAIL,
+    subject: "[REPORT]",
+    text: link,
+    html: link
+  });
+
+  if (!mail.accepted.length) {
+    throw new CustomError("Couldn't submit the report. Try again later.");
+  }
+}
+
 module.exports = {
   changeEmail,
   verification,
   resetPasswordToken,
+  sendReportEmail,
 }

+ 2 - 2
server/queries/index.js

@@ -1,5 +1,5 @@
-// const visit = require("./visit.queries");
 const domain = require("./domain.queries");
+const visit = require("./visit.queries");
 const link = require("./link.queries");
 const user = require("./user.queries");
 const host = require("./host.queries");
@@ -11,5 +11,5 @@ module.exports = {
   ip,
   link,
   user,
-  // visit
+  visit
 };

+ 180 - 0
server/queries/visit.queries.js

@@ -0,0 +1,180 @@
+const { isAfter, subDays, subHours, set } = require("date-fns");
+
+const utils = require("../utils");
+const redis = require("../redis");
+const knex = require("../knex");
+
+async function add(params) {
+  const data = {
+    ...params,
+    country: params.country.toLowerCase(),
+    referrer: params.referrer.toLowerCase()
+  };
+
+  const visit = await knex("visits")
+    .where({ link_id: params.id })
+    .andWhere(
+      knex.raw("date_trunc('hour', created_at) = date_trunc('hour', ?)", [
+        knex.fn.now()
+      ])
+    )
+    .first();
+
+  if (visit) {
+    await knex("visits")
+      .where({ id: visit.id })
+      .increment(`br_${data.browser}`, 1)
+      .increment(`os_${data.os}`, 1)
+      .increment("total", 1)
+      .update({
+        updated_at: new Date().toISOString(),
+        countries: knex.raw(
+          "jsonb_set(countries, '{??}', (COALESCE(countries->>?,'0')::int + 1)::text::jsonb)",
+          [data.country, data.country]
+        ),
+        referrers: knex.raw(
+          "jsonb_set(referrers, '{??}', (COALESCE(referrers->>?,'0')::int + 1)::text::jsonb)",
+          [data.referrer, data.referrer]
+        )
+      });
+  } else {
+    await knex("visits").insert({
+      [`br_${data.browser}`]: 1,
+      countries: { [data.country]: 1 },
+      referrers: { [data.referrer]: 1 },
+      [`os_${data.os}`]: 1,
+      total: 1,
+      link_id: data.id
+    });
+  }
+
+  return visit;
+};
+
+async function find(match, total) {
+  // if (match.link_id) {
+  //   const key = redis.key.stats(match.link_id);
+  //   const cached = await redis.client.get(key);
+  //   if (cached) return JSON.parse(cached);
+  // }
+
+  const stats = {
+    lastDay: {
+      stats: utils.getInitStats(),
+      views: new Array(24).fill(0),
+      total: 0
+    },
+    lastWeek: {
+      stats: utils.getInitStats(),
+      views: new Array(7).fill(0),
+      total: 0
+    },
+    lastMonth: {
+      stats: utils.getInitStats(),
+      views: new Array(30).fill(0),
+      total: 0
+    },
+    lastYear: {
+      stats: utils.getInitStats(),
+      views: new Array(12).fill(0),
+      total: 0
+    }
+  };
+
+  const visitsStream = knex("visits").where(match).stream();
+  const nowUTC = utils.getUTCDate();
+  const now = new Date();
+
+  const periods = utils.getStatsPeriods(now);
+
+  for await (const visit of visitsStream) {
+    periods.forEach(([type, fromDate]) => {
+      const isIncluded = isAfter(new Date(visit.created_at), fromDate);
+      if (!isIncluded) return;
+      const diffFunction = utils.getDifferenceFunction(type);
+      const diff = diffFunction(now, new Date(visit.created_at));
+      const index = stats[type].views.length - diff - 1;
+      const view = stats[type].views[index];
+      const period = stats[type].stats;
+      stats[type].stats = {
+        browser: {
+          chrome: period.browser.chrome + visit.br_chrome,
+          edge: period.browser.edge + visit.br_edge,
+          firefox: period.browser.firefox + visit.br_firefox,
+          ie: period.browser.ie + visit.br_ie,
+          opera: period.browser.opera + visit.br_opera,
+          other: period.browser.other + visit.br_other,
+          safari: period.browser.safari + visit.br_safari
+        },
+        os: {
+          android: period.os.android + visit.os_android,
+          ios: period.os.ios + visit.os_ios,
+          linux: period.os.linux + visit.os_linux,
+          macos: period.os.macos + visit.os_macos,
+          other: period.os.other + visit.os_other,
+          windows: period.os.windows + visit.os_windows
+        },
+        country: {
+          ...period.country,
+          ...Object.entries(visit.countries).reduce(
+            (obj, [country, count]) => ({
+              ...obj,
+              [country]: (period.country[country] || 0) + count
+            }),
+            {}
+          )
+        },
+        referrer: {
+          ...period.referrer,
+          ...Object.entries(visit.referrers).reduce(
+            (obj, [referrer, count]) => ({
+              ...obj,
+              [referrer]: (period.referrer[referrer] || 0) + count
+            }),
+            {}
+          )
+        }
+      };
+      stats[type].views[index] += visit.total;
+      stats[type].total += visit.total;
+    });
+  }
+
+  const response = {
+    lastYear: {
+      stats: utils.statsObjectToArray(stats.lastYear.stats),
+      views: stats.lastYear.views,
+      total: stats.lastYear.total
+    },
+    lastDay: {
+      stats: utils.statsObjectToArray(stats.lastDay.stats),
+      views: stats.lastDay.views,
+      total: stats.lastDay.total
+    },
+    lastMonth: {
+      stats: utils.statsObjectToArray(stats.lastMonth.stats),
+      views: stats.lastMonth.views,
+      total: stats.lastMonth.total
+    },
+    lastWeek: {
+      stats: utils.statsObjectToArray(stats.lastWeek.stats),
+      views: stats.lastWeek.views,
+      total: stats.lastWeek.total
+    },
+    updatedAt: new Date().toISOString()
+  };
+
+  if (match.link_id) {
+    const cacheTime = utils.getStatsCacheTime(total);
+    const key = redis.key.stats(match.link_id);
+    redis.client.set(key, JSON.stringify(response), "EX", cacheTime);
+  }
+
+  return response;
+};
+
+
+module.exports = {
+  add,
+  find,
+}

+ 0 - 243
server/queries/visit.queries.ts

@@ -1,243 +0,0 @@
-import { isAfter, subDays, set } from "date-fns";
-
-import * as utils from "../utils/utils";
-import redisClient, * as redis from "../redis";
-import knex from "../knex";
-
-interface Add {
-  browser: string;
-  country: string;
-  domain?: string;
-  id: number;
-  os: string;
-  referrer: string;
-}
-
-export const add = async (params: Add) => {
-  const data = {
-    ...params,
-    country: params.country.toLowerCase(),
-    referrer: params.referrer.toLowerCase()
-  };
-
-  const visit = await knex<Visit>("visits")
-    .where({ link_id: params.id })
-    .andWhere(
-      knex.raw("date_trunc('hour', created_at) = date_trunc('hour', ?)", [
-        knex.fn.now()
-      ])
-    )
-    .first();
-
-  if (visit) {
-    await knex("visits")
-      .where({ id: visit.id })
-      .increment(`br_${data.browser}`, 1)
-      .increment(`os_${data.os}`, 1)
-      .increment("total", 1)
-      .update({
-        updated_at: new Date().toISOString(),
-        countries: knex.raw(
-          "jsonb_set(countries, '{??}', (COALESCE(countries->>?,'0')::int + 1)::text::jsonb)",
-          [data.country, data.country]
-        ),
-        referrers: knex.raw(
-          "jsonb_set(referrers, '{??}', (COALESCE(referrers->>?,'0')::int + 1)::text::jsonb)",
-          [data.referrer, data.referrer]
-        )
-      });
-  } else {
-    await knex<Visit>("visits").insert({
-      [`br_${data.browser}`]: 1,
-      countries: { [data.country]: 1 },
-      referrers: { [data.referrer]: 1 },
-      [`os_${data.os}`]: 1,
-      total: 1,
-      link_id: data.id
-    });
-  }
-
-  return visit;
-};
-
-interface StatsResult {
-  stats: {
-    browser: { name: string; value: number }[];
-    os: { name: string; value: number }[];
-    country: { name: string; value: number }[];
-    referrer: { name: string; value: number }[];
-  };
-  views: number[];
-}
-
-interface IGetStatsResponse {
-  allTime: StatsResult;
-  lastDay: StatsResult;
-  lastMonth: StatsResult;
-  lastWeek: StatsResult;
-  updatedAt: string;
-}
-
-export const find = async (match: Partial<Visit>, total: number) => {
-  if (match.link_id) {
-    const key = redis.key.stats(match.link_id);
-    const cached = await redisClient.get(key);
-    if (cached) return JSON.parse(cached);
-  }
-
-  const stats = {
-    lastDay: {
-      stats: utils.getInitStats(),
-      views: new Array(24).fill(0)
-    },
-    lastWeek: {
-      stats: utils.getInitStats(),
-      views: new Array(7).fill(0)
-    },
-    lastMonth: {
-      stats: utils.getInitStats(),
-      views: new Array(30).fill(0)
-    },
-    allTime: {
-      stats: utils.getInitStats(),
-      views: new Array(18).fill(0)
-    }
-  };
-
-  const visitsStream: any = knex<Visit>("visits").where(match).stream();
-  const nowUTC = utils.getUTCDate();
-  const now = new Date();
-
-  for await (const visit of visitsStream as Visit[]) {
-    utils.STATS_PERIODS.forEach(([days, type]) => {
-      const isIncluded = isAfter(
-        new Date(visit.created_at),
-        subDays(nowUTC, days)
-      );
-      if (isIncluded) {
-        const diffFunction = utils.getDifferenceFunction(type);
-        const diff = diffFunction(now, new Date(visit.created_at));
-        const index = stats[type].views.length - diff - 1;
-        const view = stats[type].views[index];
-        const period = stats[type].stats;
-        stats[type].stats = {
-          browser: {
-            chrome: period.browser.chrome + visit.br_chrome,
-            edge: period.browser.edge + visit.br_edge,
-            firefox: period.browser.firefox + visit.br_firefox,
-            ie: period.browser.ie + visit.br_ie,
-            opera: period.browser.opera + visit.br_opera,
-            other: period.browser.other + visit.br_other,
-            safari: period.browser.safari + visit.br_safari
-          },
-          os: {
-            android: period.os.android + visit.os_android,
-            ios: period.os.ios + visit.os_ios,
-            linux: period.os.linux + visit.os_linux,
-            macos: period.os.macos + visit.os_macos,
-            other: period.os.other + visit.os_other,
-            windows: period.os.windows + visit.os_windows
-          },
-          country: {
-            ...period.country,
-            ...Object.entries(visit.countries).reduce(
-              (obj, [country, count]) => ({
-                ...obj,
-                [country]: (period.country[country] || 0) + count
-              }),
-              {}
-            )
-          },
-          referrer: {
-            ...period.referrer,
-            ...Object.entries(visit.referrers).reduce(
-              (obj, [referrer, count]) => ({
-                ...obj,
-                [referrer]: (period.referrer[referrer] || 0) + count
-              }),
-              {}
-            )
-          }
-        };
-        stats[type].views[index] = view + visit.total;
-      }
-    });
-
-    const allTime = stats.allTime.stats;
-    const diffFunction = utils.getDifferenceFunction("allTime");
-    const diff = diffFunction(
-      set(new Date(), { date: 1 }),
-      set(new Date(visit.created_at), { date: 1 })
-    );
-    const index = stats.allTime.views.length - diff - 1;
-    const view = stats.allTime.views[index];
-    stats.allTime.stats = {
-      browser: {
-        chrome: allTime.browser.chrome + visit.br_chrome,
-        edge: allTime.browser.edge + visit.br_edge,
-        firefox: allTime.browser.firefox + visit.br_firefox,
-        ie: allTime.browser.ie + visit.br_ie,
-        opera: allTime.browser.opera + visit.br_opera,
-        other: allTime.browser.other + visit.br_other,
-        safari: allTime.browser.safari + visit.br_safari
-      },
-      os: {
-        android: allTime.os.android + visit.os_android,
-        ios: allTime.os.ios + visit.os_ios,
-        linux: allTime.os.linux + visit.os_linux,
-        macos: allTime.os.macos + visit.os_macos,
-        other: allTime.os.other + visit.os_other,
-        windows: allTime.os.windows + visit.os_windows
-      },
-      country: {
-        ...allTime.country,
-        ...Object.entries(visit.countries).reduce(
-          (obj, [country, count]) => ({
-            ...obj,
-            [country]: (allTime.country[country] || 0) + count
-          }),
-          {}
-        )
-      },
-      referrer: {
-        ...allTime.referrer,
-        ...Object.entries(visit.referrers).reduce(
-          (obj, [referrer, count]) => ({
-            ...obj,
-            [referrer]: (allTime.referrer[referrer] || 0) + count
-          }),
-          {}
-        )
-      }
-    };
-    stats.allTime.views[index] = view + visit.total;
-  }
-
-  const response: IGetStatsResponse = {
-    allTime: {
-      stats: utils.statsObjectToArray(stats.allTime.stats),
-      views: stats.allTime.views
-    },
-    lastDay: {
-      stats: utils.statsObjectToArray(stats.lastDay.stats),
-      views: stats.lastDay.views
-    },
-    lastMonth: {
-      stats: utils.statsObjectToArray(stats.lastMonth.stats),
-      views: stats.lastMonth.views
-    },
-    lastWeek: {
-      stats: utils.statsObjectToArray(stats.lastWeek.stats),
-      views: stats.lastWeek.views
-    },
-    updatedAt: new Date().toISOString()
-  };
-
-  if (match.link_id) {
-    const cacheTime = utils.getStatsCacheTime(total);
-    const key = redis.key.stats(match.link_id);
-    redisClient.set(key, JSON.stringify(response), "EX", cacheTime);
-  }
-
-  return response;
-};

+ 5 - 0
server/queues/index.js

@@ -0,0 +1,5 @@
+const { visit } = require("./queues");
+
+module.exports = {
+  visit,
+};

+ 0 - 7
server/queues/index.ts

@@ -1,7 +0,0 @@
-import { visit } from "./queues";
-
-const queues = {
-  visit
-};
-
-export default queues;

+ 75 - 0
server/queues/queues.js

@@ -0,0 +1,75 @@
+const Queue = require("bull");
+const path = require("path");
+
+const env = require("../env");
+
+const redis = {
+  port: env.REDIS_PORT,
+  host: env.REDIS_HOST,
+  ...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD })
+};
+
+function onComplete(job) { 
+  console.log('complete');
+  return job.remove();
+}
+
+const visit = new Queue("visit", { redis });
+
+const a = require(__dirname + "/visit.js");
+// visit.clean(5000, "completed");
+visit.process(__dirname + "/visit.js");
+visit.on("completed", onComplete);
+
+visit.on('error', function (error) {
+  console.log('error');
+})
+
+visit.on('waiting', function (jobId) {
+  console.log('waiting');
+});
+
+visit.on('active', function (job, jobPromise) {
+  console.log('active');
+})
+
+visit.on('stalled', function (job) {
+  console.log('stalled');
+})
+
+visit.on('lock-extension-failed', function (job, err) {
+  console.log('lock-extension-failed');
+});
+
+visit.on('progress', function (job, progress) {
+  console.log('progress');
+})
+
+visit.on('failed', function (job, err) {
+  console.log(err);
+  console.log('failed');
+})
+
+visit.on('paused', function () {
+  console.log('paused');
+})
+
+visit.on('resumed', function (job) {
+  console.log('resumed');
+})
+
+visit.on('cleaned', function (jobs, type) {
+  console.log('cleaned');
+});
+
+visit.on('drained', function () {
+  console.log('drained');
+});
+
+visit.on('removed', function (job) {
+  console.log('removed');
+});
+
+module.exports = { 
+  visit,
+}

+ 0 - 20
server/queues/queues.ts

@@ -1,20 +0,0 @@
-import Queue from "bull";
-import path from "path";
-
-import env from "../env";
-
-const redis = {
-  port: env.REDIS_PORT,
-  host: env.REDIS_HOST,
-  ...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD })
-};
-
-const removeJob = job => job.remove();
-
-export const visit = new Queue("visit", { redis });
-
-visit.clean(5000, "completed");
-
-visit.process(8, path.resolve(__dirname, "visit.js"));
-
-visit.on("completed", removeJob);

+ 24 - 14
server/queues/visit.ts → server/queues/visit.js

@@ -1,30 +1,40 @@
-import useragent from "useragent";
-import geoip from "geoip-lite";
-import URL from "url";
+const useragent = require("useragent");
+const geoip = require("geoip-lite");
+const URL = require("url");
 
-import query from "../queries";
-import { getStatsLimit, removeWww } from "../utils/utils";
+const { getStatsLimit, removeWww } = require("../utils");
+const query = require("../queries");
 
 const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
 const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"];
-const filterInBrowser = (agent) => (item) =>
-  agent.family.toLowerCase().includes(item.toLocaleLowerCase());
-const filterInOs = (agent) => (item) =>
-  agent.os.family.toLowerCase().includes(item.toLocaleLowerCase());
 
-export default function visit({ data }) {
-  const tasks = [];
+function filterInBrowser(agent) {
+  return function(item) {
+    return agent.family.toLowerCase().includes(item.toLocaleLowerCase());
+  }
+}
 
-  tasks.push(query.link.incrementVisit({ id: data.link.id }));
+function filterInOs(agent) {
+  return function(item) {
+    return agent.os.family.toLowerCase().includes(item.toLocaleLowerCase());
+  }
+}
+
+module.exports = function({ data }) {
+  const tasks = [];
+  
+  tasks.push(query.link.incrementVisit({ id:  data.link.id }));
 
   if (data.link.visit_count < getStatsLimit()) {
     const agent = useragent.parse(data.headers["user-agent"]);
     const [browser = "Other"] = browsersList.filter(filterInBrowser(agent));
     const [os = "Other"] = osList.filter(filterInOs(agent));
     const referrer =
-      data.referrer && removeWww(URL.parse(data.referrer).hostname);
+    data.referrer && removeWww(URL.parse(data.referrer).hostname);
     const location = geoip.lookup(data.realIP);
     const country = location && location.country;
+
+    
     tasks.push(
       query.visit.add({
         browser: browser.toLowerCase(),
@@ -37,4 +47,4 @@ export default function visit({ data }) {
   }
 
   return Promise.all(tasks);
-}
+}

+ 0 - 1
server/renders/index.js

@@ -1 +0,0 @@
-module.exports = require("./renders");

+ 0 - 27
server/renders/renders.helper.js

@@ -1,27 +0,0 @@
-function renderError(res, template, errors) {
-  const error = errors[0].msg;
-    
-  const params = {};
-
-  errors.forEach(e => {
-    if (params[e.param]) return;
-    params[e.param + "_error"] = e.msg;
-  });
-
-  res.render(template, {
-    layout: null,
-    error,
-    ...params
-  });
-}
-
-/**
- * @type {import("express").Handler}
- */
-function addErrorRenderer(req, res, next) {
-  res.render.error = (template, errors) => renderError(res, template, errors);  
-}
-
-module.exports = {
-  addErrorRenderer,
-}

+ 8 - 1
server/routes/auth.routes.js

@@ -4,6 +4,7 @@ const { Router } = require("express");
 const validators = require("../handlers/validators.handler");
 const helpers = require("../handlers/helpers.handler");
 const auth = require("../handlers/auth.handler");
+const utils = require("../utils");
 
 const router = Router();
 
@@ -52,6 +53,12 @@ router.post(
   asyncHandler(auth.generateApiKey)
 );
 
-// router.post("/reset-password", asyncHandler(auth.resetPasswordRequest));
+router.post(
+  "/reset-password",
+  helpers.viewTemplate("partials/reset_password/form"),
+  validators.resetPassword,
+  asyncHandler(helpers.verify),
+  asyncHandler(auth.resetPasswordRequest)
+);
 
 module.exports = router;

+ 2 - 2
server/routes/health.routes.ts → server/routes/health.routes.js

@@ -1,7 +1,7 @@
-import { Router } from "express";
+const { Router } = require("express");
 
 const router = Router();
 
 router.get("/", (_, res) => res.send("OK"));
 
-export default router;
+module.exports = router;

+ 24 - 19
server/routes/link.routes.js

@@ -66,27 +66,32 @@ router.post(
   asyncHandler(link.ban)
 );
 
-// router.get(
-//   "/:id/stats",
-//   asyncHandler(auth.apikey),
-//   asyncHandler(auth.jwt),
-//   validators.getStats,
-//   asyncHandler(link.stats)
-// );
+router.get(
+  "/:id/stats",
+  helpers.viewTemplate("partials/stats"),
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  validators.getStats,
+  asyncHandler(helpers.verify),
+  asyncHandler(link.stats)
+);
 
-// router.post(
-//   "/:id/protected",
-//   validators.redirectProtected,
-//   asyncHandler(helpers.verify),
-//   asyncHandler(link.redirectProtected)
-// );
+router.post(
+  "/:id/protected",
+  helpers.viewTemplate("partials/protected/form"),
+  locals.protected,
+  validators.redirectProtected,
+  asyncHandler(helpers.verify),
+  asyncHandler(link.redirectProtected)
+);
 
-// router.post(
-//   "/report",
-//   validators.reportLink,
-//   asyncHandler(helpers.verify),
-//   asyncHandler(link.report)
-// );
+router.post(
+  "/report",
+  helpers.viewTemplate("partials/report/form"),
+  validators.reportLink,
+  asyncHandler(helpers.verify),
+  asyncHandler(link.report)
+);
 
 
 module.exports = router;

+ 65 - 1
server/renders/renders.js → server/routes/renders.routes.js

@@ -2,8 +2,8 @@ const asyncHandler = require("express-async-handler");
 const { Router } = require("express");
 
 const helpers = require("../handlers/helpers.handler");
+const renders = require("../handlers/renders.handler");
 const auth = require("../handlers/auth.handler");
-const renders = require("./renders.handler");
 
 const router = Router();
 
@@ -27,6 +27,12 @@ router.get(
   asyncHandler(renders.logout)
 );
 
+router.get(
+  "/404", 
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(renders.notFound)
+);
+
 router.get(
   "/settings",
   asyncHandler(auth.jwtLoose),
@@ -34,6 +40,57 @@ router.get(
   asyncHandler(renders.settings)
 );
 
+router.get(
+  "/stats",
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(helpers.addUserLocals),
+  asyncHandler(renders.stats)
+);
+
+router.get(
+  "/banned",
+  asyncHandler(renders.banned)
+);
+
+router.get(
+  "/report",
+  asyncHandler(renders.report)
+);
+
+router.get(
+  "/reset-password",
+  asyncHandler(renders.resetPassword)
+);
+
+router.get(
+  "/reset-password/:resetPasswordToken",
+  asyncHandler(auth.resetPassword),
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(helpers.addUserLocals),
+  asyncHandler(renders.resetPasswordResult)
+);
+
+router.get(
+  "/verify-email/:changeEmailToken",
+  asyncHandler(auth.changeEmail),
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(helpers.addUserLocals),
+  asyncHandler(renders.verifyChangeEmail)
+);
+
+router.get(
+  "/verify/:verificationToken",
+  asyncHandler(auth.verify),
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(helpers.addUserLocals),
+  asyncHandler(renders.verify)
+);
+
+router.get(
+  "/terms",
+  asyncHandler(renders.terms)
+);
+
 // partial renders
 router.get(
   "/confirm-link-delete", 
@@ -73,4 +130,11 @@ router.get(
   asyncHandler(renders.confirmDomainDelete)
 );
 
+router.get(
+  "/get-report-email", 
+  helpers.addNoLayoutLocals,
+  helpers.viewTemplate("partials/report/email"),
+  asyncHandler(renders.getReportEmail)
+);
+
 module.exports = router;

+ 15 - 9
server/routes/routes.js

@@ -1,19 +1,25 @@
 const { Router } = require("express");
 
 const helpers = require("./../handlers/helpers.handler");
+const renders = require("./renders.routes");
 const domains = require("./domain.routes");
-// import health from "./health.routes";
+const health = require("./health.routes");
 const link = require("./link.routes");
 const user = require("./user.routes");
 const auth = require("./auth.routes");
 
-const router = Router();
+const apiRouter = Router();
+const renderRouter = Router();
 
-router.use(helpers.addNoLayoutLocals);
-router.use("/domains", domains);
-// router.use("/health", health);
-router.use("/links", link);
-router.use("/users", user);
-router.use("/auth", auth);
+renderRouter.use(renders);
+apiRouter.use(helpers.addNoLayoutLocals);
+apiRouter.use("/domains", domains);
+apiRouter.use("/health", health);
+apiRouter.use("/links", link);
+apiRouter.use("/users", user);
+apiRouter.use("/auth", auth);
 
-module.exports = router;
+module.exports = {
+  api: apiRouter,
+  render: renderRouter,
+};

+ 18 - 33
server/server.js

@@ -1,8 +1,9 @@
 const env = require("./env");
 
-// import asyncHandler from "express-async-handler";
-// import passport from "passport";
+const asyncHandler = require("express-async-handler");
 const cookieParser = require("cookie-parser");
+const compression = require("compression");
+const passport = require("passport");
 const express = require("express");
 const helmet = require("helmet");
 const morgan = require("morgan");
@@ -10,18 +11,21 @@ const path = require("path");
 const hbs = require("hbs");
 
 const helpers = require("./handlers/helpers.handler");
-// import * as links from "./handlers/links";
-// import * as auth from "./handlers/auth";
+const links = require("./handlers/links.handler");
+const { stream } = require("./config/winston");
 const routes = require("./routes");
-const renders = require("./renders");
 const utils = require("./utils");
-const { stream } = require("./config/winston")
 
 // import "./cron";
 require("./passport");
 
 const app = express();
 
+// enable gzip on dev
+if (env.isDev) {
+  app.use(compression());
+}
+
 // TODO: comments
 app.set("trust proxy", true);
 
@@ -35,7 +39,7 @@ app.use(express.json());
 app.use(express.urlencoded({ extended: true }));
 app.use(express.static("static"));
 
-// app.use(passport.initialize());
+app.use(passport.initialize());
 // app.use(helpers.ip);
 app.use(helpers.isHTML);
 app.use(helpers.addConfigLocals);
@@ -45,38 +49,19 @@ app.set("view engine", "hbs");
 app.set("views", path.join(__dirname, "views"));
 utils.registerHandlebarsHelpers();
 
-app.use("/", renders);
-
-// app.use(asyncHandler(links.redirectCustomDomain));
+app.use("/", routes.render);
 
-app.use("/api/v2", routes);
-app.use("/api", routes);
+// if is custom domain, redirect to the set homepage
+app.use(asyncHandler(links.redirectCustomDomainHomepage));
 
-  // server.get(
-  //   "/reset-password/:resetPasswordToken?",
-  //   asyncHandler(auth.resetPassword),
-  //   (req, res) => app.render(req, res, "/reset-password", { token: req.token })
-  // );
+app.use("/api/v2", routes.api);
+app.use("/api", routes.api);
 
-  // server.get(
-  //   "/verify-email/:changeEmailToken",
-  //   asyncHandler(auth.changeEmail),
-  //   (req, res) => app.render(req, res, "/verify-email", { token: req.token })
-  // );
-
-  // server.get(
-  //   "/verify/:verificationToken?",
-  //   asyncHandler(auth.verify),
-  //   (req, res) => app.render(req, res, "/verify", { token: req.token })
-  // );
-
-  // server.get("/:id", asyncHandler(links.redirect(app)));
+// finally, redirect the short link to the target
+app.get("/:id", asyncHandler(links.redirect));
 
 // Error handler
 app.use(helpers.error);
-
-  // Handler everything else by Next.js
-  // server.get("*", (req, res) => handle(req, res));
   
 app.listen(env.PORT, () => {
   console.log(`> Ready on http://localhost:${env.PORT}`);

Datei-Diff unterdrückt, da er zu groß ist
+ 48 - 0
server/utils/map.json


+ 17 - 12
server/utils/utils.js

@@ -2,7 +2,7 @@ const ms = require("ms");
 const path = require("path");
 const nanoid = require("nanoid/generate");
 const JWT = require("jsonwebtoken");
-const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays } = require("date-fns");
+const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears } = require("date-fns");
 const hbs = require("hbs");
 
 const env = require("../env");
@@ -16,8 +16,6 @@ class CustomError extends Error {
   }
 }
 
-const query = require("../queries");
-
 function isAdmin(email) {
   return env.ADMIN_EMAILS.split(",")
     .map((e) => e.trim())
@@ -37,7 +35,7 @@ function signToken(user) {
     )
 }
 
-async function generateId(domain_id) {
+async function generateId(query, domain_id) {
   const address = nanoid(
     "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
     env.LINK_LENGTH
@@ -53,7 +51,7 @@ function addProtocol(url) {
 }
 
 function getShortURL(address, domain) {
-  const protocol = env.CUSTOM_DOMAIN_USE_HTTPS || !domain ? "https://" : "http://";
+  const protocol = (env.CUSTOM_DOMAIN_USE_HTTPS || !domain) && !env.isDev ? "https://" : "http://";
   const link = `${domain || env.DEFAULT_DOMAIN}/${address}`;
   const url = `${protocol}${link}`;
   return { link, url };
@@ -96,7 +94,7 @@ function getDifferenceFunction(type) {
   if (type === "lastDay") return differenceInHours;
   if (type === "lastWeek") return differenceInDays;
   if (type === "lastMonth") return differenceInDays;
-  if (type === "allTime") return differenceInMonths;
+  if (type === "lastYear") return differenceInMonths;
   throw new Error("Unknown type.");
 }
 
@@ -110,11 +108,14 @@ function getUTCDate(dateString) {
   );
 }
 
-const STATS_PERIODS = [
-  [1, "lastDay"],
-  [7, "lastWeek"],
-  [30, "lastMonth"]
-];
+function getStatsPeriods(now) {
+  return [
+    ["lastDay", subHours(now, 24)],
+    ["lastWeek", subDays(now, 7)],
+    ["lastMonth", subDays(now, 30)],
+    ["lastYear", subMonths(now, 12)],
+  ]
+}
 
 const preservedURLs = [
   "login",
@@ -233,6 +234,10 @@ function registerHandlebarsHelpers() {
   hbs.registerHelper("ifEquals", function(arg1, arg2, options) {
     return (arg1 === arg2) ? options.fn(this) : options.inverse(this);
   });
+
+  hbs.registerHelper("json", function(context) {
+    return JSON.stringify(context);
+  });
   
   const blocks = {};
 
@@ -270,6 +275,6 @@ module.exports = {
   sanitize,
   signToken,
   sleep,
-  STATS_PERIODS,
+  getStatsPeriods,
   statsObjectToArray,
 }

+ 10 - 0
server/views/404.hbs

@@ -0,0 +1,10 @@
+{{> header}}
+<div id="notfound" class="section-container">
+  <h2>
+    404 | Link could not be found.
+  </h2>
+  <a class="back-to-home" href="/">
+    ← Back to homepage
+  </a>
+</div>
+{{> footer}}

+ 14 - 0
server/views/banned.hbs

@@ -0,0 +1,14 @@
+{{> header}}
+<section id="banned" class="section-container">
+  <h2>
+    Link has been banned and removed because of 
+    <span class="bold underline">malware or scam</span>.
+  </h2>
+  <h4>
+    If you noticed a malware/scam link shortened by {{default_domain}}, 
+    <a href="/report" title="Send report">
+      send us a report
+    </a>.
+  </h4>
+</section>
+{{> footer}}

+ 0 - 1
server/views/homepage.hbs

@@ -5,7 +5,6 @@
 {{/if}}
 {{#unless user}}
   {{> introduction}}
-  {{> features}}
   {{> browser_extensions}}
 {{/unless}}
 {{> footer}}

+ 1 - 1
server/views/layout.hbs

@@ -8,7 +8,7 @@
   <link rel="icon" sizes="16x16" href="/images/favicon-16x16.png" />
   <link rel="apple-touch-icon" href="/images/favicon-196x196.png" />
   <link rel="mask-icon" href="/images/icon.svg" color="blue" />
-  <link rel="manifest" href="manifest.webmanifest" />
+  <link rel="manifest" href="/manifest.webmanifest" />
   <meta name="theme-color" content="#f3f3f3" />
   <meta property="fb:app_id" content="123456789" />
   <meta name="htmx-config" content='{"withCredentials":true}'>

+ 6 - 5
server/views/partials/auth/form.hbs

@@ -25,11 +25,12 @@
   {{!-- TODO: Agree with terms --}}
   <div class="buttons-wrapper">
     <button type="submit" class="primary login">
-      <svg class="with-text icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4m-5-4 5-5-5-5m3.8 5H3"/></svg>
-      <svg class="with-text spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
+      <span>{{> icons/login}}</span>
+      <span>{{> icons/spinner}}</span>
       Log in
     </button>
     <button 
+      type="button"
       class="secondary signup" 
       hx-post="/api/auth/signup" 
       hx-target="#login-signup" 
@@ -40,12 +41,12 @@
       hx-on:htmx:before-request="htmx.addClass('#login-signup', 'signup')"
       hx-on:htmx:after-request="htmx.removeClass('#login-signup', 'signup')"
     >
-        <svg class="with-text icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6m3-3h-6"/></svg>
-        <svg class="with-text spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
+        <span>{{> icons/new_user}}</span>
+        <span>{{> icons/spinner}}</span>
         Sign up
     </button>
   </div>
-  <a class="forgot-password" href="/forgot-password" title="Reset password">Forgot your password?</a>
+  <a class="forgot-password" href="/reset-password" title="Reset password">Forgot your password?</a>
   {{#unless errors}}
     {{#if error}}
       <p class="error">{{error}}</p>

+ 4 - 4
server/views/partials/features.hbs

@@ -3,28 +3,28 @@
   <ul>
     <li>
       <div class="icon">
-        <svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 14.7V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5.3"/><path d="m18 2 4 4-10 10H8v-4z"/></svg>
+        {{> icons/write}}
       </div>
       <h4>Managing links</h4>
       <p>Create, protect and delete your links and monitor them with detailed statistics.</p>
     </li>
     <li>
       <div class="icon">
-        <svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 3h5v5M4 20 20.2 3.8M21 16v5h-5m-1-6 5.1 5.1M4 4l5 5"/></svg>
+        {{> icons/shuffle}}
       </div>
       <h4>Custom domain</h4>
       <p>Use custom domains for your links. Add or remove them for free.</p>
     </li>
     <li>
       <div class="icon">
-        <svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9z"/></svg>
+        {{> icons/zap}}
       </div>
       <h4>API</h4>
       <p>Use the provided API to create, delete, and get URLs from anywhere.</p>
     </li>
     <li>
       <div class="icon">
-        <svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.7 0l-1.1 1-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1 7.8 7.8 7.8-7.7 1-1.1a5.5 5.5 0 0 0 0-7.8z"/></svg>
+        {{> icons/heart}}
       </div>
       <h4>Free & open source</h4>
       <p>Completely open source and free. You can host it on your own server.</p>

+ 1 - 0
server/views/partials/icons/arrow_left.hbs

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="-5 -5 24 24"><path d="m3.41 7.66 3.95 3.95a1 1 0 0 1-1.41 1.41L.29 7.36a1 1 0 0 1 0-1.41L5.95.29a1 1 0 1 1 1.41 1.42L3.41 5.66H13a1 1 0 0 1 0 2z"/></svg>

+ 1 - 0
server/views/partials/icons/chevron_left.hbs

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg>

+ 1 - 0
server/views/partials/icons/chevron_right.hbs

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>

+ 1 - 0
server/views/partials/icons/eye.hbs

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="-2 -6 24 24"><path d="M18 6c0-1.8-3.8-4-8-4S2 4.2 2 6s3.8 4 8 4 8-2.2 8-4m-8 6C5 12 0 9.3 0 6s5-6 10-6 10 2.7 10 6-5 6-10 6m0-2a4 4 0 1 1 0-8 4 4 0 0 1 0 8m0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4"/></svg>

+ 1 - 0
server/views/partials/icons/heart.hbs

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.7 0l-1.1 1-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1 7.8 7.8 7.8-7.7 1-1.1a5.5 5.5 0 0 0 0-7.8z"/></svg>

+ 1 - 0
server/views/partials/icons/login.hbs

@@ -0,0 +1 @@
+<svg class="with-text icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4m-5-4 5-5-5-5m3.8 5H3"/></svg>

+ 1 - 0
server/views/partials/icons/new_user.hbs

@@ -0,0 +1 @@
+<svg class="with-text icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6m3-3h-6"/></svg>

+ 1 - 0
server/views/partials/icons/send.hbs

@@ -0,0 +1 @@
+<svg class="send" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="m2 21 21-9L2 3v7l15 2-15 2z"/></svg>

+ 1 - 0
server/views/partials/icons/shuffle.hbs

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 3h5v5M4 20 20.2 3.8M21 16v5h-5m-1-6 5.1 5.1M4 4l5 5"/></svg>

+ 1 - 0
server/views/partials/icons/write.hbs

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 14.7V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5.3"/><path d="m18 2 4 4-10 10H8v-4z"/></svg>

+ 1 - 0
server/views/partials/icons/x.hbs

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg>

+ 7 - 2
server/views/partials/links/actions.hbs

@@ -9,9 +9,14 @@
       {{> icons/stop}}
     </button>
   {{/if}}
-  <button class="action stats">
+  <a
+    class="button action stats"
+    href="/stats?id={{id}}"
+    title="Stats"
+    class="action stats"
+  >
     {{> icons/chart}}
-  </button>
+  </a>
   <button
     class="action qrcode"
     hx-on:click="handleQRCode(this)"

+ 2 - 1
server/views/partials/links/dialog/ban.hbs

@@ -22,8 +22,9 @@
     </label>
   </div>
   <div class="buttons">
-    <button hx-on:click="closeDialog()">Cancel</button>
+    <button type="button" hx-on:click="closeDialog()">Cancel</button>
     <button 
+      type="button"
       class="danger confirm" 
       hx-post="/api/links/admin/ban/{id}" 
       hx-ext="path-params" 

+ 2 - 2
server/views/partials/links/dialog/ban_success.hbs

@@ -1,12 +1,12 @@
 <div class="content">
   <div class="icon success">
-    <svg class="check" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>
+    {{> icons/check}}
   </div>
   <p>
     The link <b>"{{link}}"</b> is banned.
   </p>
   <div class="buttons">
-    <button hx-on:click="closeDialog()">Close</button>
+    <button type="button" hx-on:click="closeDialog()">Close</button>
   </div>
 </div>
 

+ 4 - 3
server/views/partials/links/dialog/delete.hbs

@@ -4,8 +4,9 @@
     Are you sure do you want to delete the link &quot;<b>{{link}}</b>&quot;?
   </p>
   <div class="buttons">
-    <button hx-on:click="closeDialog()">Cancel</button>
+    <button type="button" hx-on:click="closeDialog()">Cancel</button>
     <button 
+      type="button"
       class="danger confirm" 
       hx-delete="/api/links/{id}" 
       hx-ext="path-params" 
@@ -15,10 +16,10 @@
       hx-indicator="closest .content"
       hx-select-oob="#dialog-error"
     >
-      <svg class="with-text action" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M3 6h18m-2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m-6 5v6m4-6v6"/></svg>
+      <span>{{> icons/trash}}</span>
       Delete
     </button>
-    <svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
+    {{> icons/spinner}}
   </div>
   <div id="dialog-error">
     {{#if error}}

+ 2 - 2
server/views/partials/links/dialog/delete_success.hbs

@@ -1,12 +1,12 @@
 <div class="content">
   <div class="icon success">
-    <svg class="check" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>
+    {{> icons/check}}
   </div>
   <p>
     Your link <b>"{{link}}"</b> has been deleted.
   </p>
   <div class="buttons">
-    <button hx-on:click="closeDialog()">Close</button>
+    <button type="button" hx-on:click="closeDialog()">Close</button>
   </div>
 </div>
 

+ 1 - 1
server/views/partials/links/dialog/frame.hbs

@@ -2,7 +2,7 @@
   <div class="box">
     <div class="content-wrapper"></div>
     <div class="loading">
-      <svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
+      {{> icons/spinner}}
     </div>
   </div>
 </div>

+ 1 - 1
server/views/partials/links/dialog/message.hbs

@@ -5,7 +5,7 @@
     <p>{{message}}</p>
   {{/if}}
   <div class="buttons">
-    <button hx-on:click="closeDialog()">Close</button>
+    <button type="button" hx-on:click="closeDialog()">Close</button>
   </div>
 </div>
 

+ 2 - 1
server/views/partials/links/edit.hbs

@@ -77,6 +77,7 @@
     </div>
     <div>
       <button 
+        type="button"
         onclick="
           const tr = closest('tr');
           if (!tr) return;
@@ -86,7 +87,7 @@
       >
         Close
       </button>
-      <button class="primary">
+      <button type="submit" class="primary">
         <span class="reload">
           {{> icons/reload}}
         </span>

+ 1 - 1
server/views/partials/links/loading.hbs

@@ -8,7 +8,7 @@
   {{else}}
     <tr class="loading-placeholder">
       <td>
-        <svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
+        {{> icons/spinner}}
         Loading links...
       </td>
     </tr>

+ 8 - 8
server/views/partials/links/nav.hbs

@@ -1,16 +1,16 @@
 <th class="nav" >
   <div class="limit">
-    <button class="table-nav" onclick="setLinksLimit(event)" disabled="true">10</button>
-    <button class="table-nav" onclick="setLinksLimit(event)">20</button>
-    <button class="table-nav" onclick="setLinksLimit(event)">50</button>
+    <button type="button" class="nav" onclick="setLinksLimit(event)" disabled="true">10</button>
+    <button type="button" class="nav" onclick="setLinksLimit(event)">20</button>
+    <button type="button" class="nav" onclick="setLinksLimit(event)">50</button>
   </div>
-  <div class="table-nav-divider"></div>
+  <div class="nav-divider"></div>
   <div id="pagination" class="pagination">
-    <button class="table-nav prev" onclick="setLinksSkip(event, 'prev')" disabled="true">
-      <svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg>
+    <button type="button" class="nav prev" onclick="setLinksSkip(event, 'prev')" disabled="true">
+      {{> icons/chevron_left}}
     </button>
-    <button class="table-nav next" onclick="setLinksSkip(event, 'next')">
-      <svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>
+    <button type="button" class="nav next" onclick="setLinksSkip(event, 'next')">
+      {{> icons/chevron_right}}
     </button>
   </div>
 </th>

+ 1 - 1
server/views/partials/links/table.hbs

@@ -14,7 +14,7 @@
       load once, 
       reloadLinks from:body, 
       change from:[name='all'], 
-      click delay:100ms from:button.table-nav, 
+      click delay:100ms from:button.nav, 
       input changed delay:500ms from:[name='search'],
     "
     hx-on:htmx:after-on-load="updateLinksNav()"

+ 6 - 4
server/views/partials/links/thead.hbs

@@ -5,10 +5,12 @@
       <input id="total" name="total" type="hidden" value="{{total}}" />
       <input id="limit" name="limit" type="hidden" value="10" />
       <input id="skip" name="skip" type="hidden" value="0" />
-      <label id="all" class="checkbox">
-        <input name="all" type="checkbox" />
-        All links
-      </label>
+      {{#if @root.isAdmin}}
+        <label id="all" class="checkbox">
+          <input name="all" type="checkbox" />
+          All links
+        </label>
+      {{/if}}
     </th>
     {{> links/nav}}
   </tr>

+ 33 - 0
server/views/partials/protected/form.hbs

@@ -0,0 +1,33 @@
+<form
+  id="report-form"
+  hx-post="/api/links/{id}/protected"
+  hx-sync="this:abort"
+  hx-ext="path-params"
+  hx-vals='{"id":"{{id}}"}'
+  hx-swap="outerHTML"
+> 
+  {{#if message}}
+    <p class="success">{{message}}</p>
+  {{else}}
+    <div class="inputs-wrapper">
+      <label>
+        Password:
+        <input 
+          type="password" 
+          id="protected-link-password" 
+          name="password" 
+          placeholder="Password..."
+          hx-preserve="true"
+          class="{{#if errors.link}}error{{/if}}"
+          required 
+        />
+      </label>
+      <button type="submit" class="primary">
+        <span>{{> icons/spinner}}</span>
+        <span>{{> icons/key}}</span>
+        Unlock & Go
+      </button>
+    </div>
+    {{#if error}}<p class="error">{{error}}</p>{{/if}}
+  {{/if}}
+</form>

+ 17 - 0
server/views/partials/report/email.hbs

@@ -0,0 +1,17 @@
+<div id="report-email">
+  {{#unless report_email}}
+    <button 
+      class="link"
+      hx-get="/get-report-email"
+      hx-sync="this:abort"
+      hx-swap="innerHTML"
+      hx-target="#report-email"
+    >
+      <span class="eye-icon">{{> icons/eye}}</span>
+      <span>{{> icons/spinner}}</span>
+      show email address
+    </button>
+  {{else}}
+    {{report_email}}
+  {{/unless}}
+</div>

+ 30 - 0
server/views/partials/report/form.hbs

@@ -0,0 +1,30 @@
+<form
+  id="report-form"
+  hx-post="/api/links/report"
+  hx-sync="this:abort"
+  hx-swap="outerHTML"
+> 
+  {{#if message}}
+    <p class="success">{{message}}</p>
+  {{else}}
+    <div class="inputs-wrapper">
+      <label>
+        URL containing malware/scam:
+        <input 
+          type="text" 
+          id="link" 
+          name="link" 
+          placeholder="{{default_domain}}/example"
+          hx-preserve="true"
+          class="{{#if errors.link}}error{{/if}}"
+          required 
+        />
+      </label>
+      <button type="submit" class="primary">
+        <span>{{> icons/spinner}}</span>
+        Send report
+      </button>
+    </div>
+    {{#if error}}<p class="error">{{error}}</p>{{/if}}
+  {{/if}}
+</form>

+ 30 - 0
server/views/partials/reset_password/form.hbs

@@ -0,0 +1,30 @@
+<form
+  id="reset-password-form"
+  hx-post="/api/auth/reset-password"
+  hx-sync="this:abort"
+  hx-swap="outerHTML"
+> 
+  {{#if message}}
+    <p class="success">{{message}}</p>
+  {{else}}
+    <div class="inputs-wrapper">
+      <label>
+        Email address:
+        <input 
+          id="reset-password-email" 
+          name="email" 
+          type="email" 
+          placeholder="Email address..."
+          hx-preserve="true"
+          class="{{#if errors.email}}error{{/if}}"
+          required 
+        />
+      </label>
+      <button type="submit" class="primary">
+        <span>{{> icons/spinner}}</span>
+        Reset password
+      </button>
+    </div>
+    {{#if error}}<p class="error">{{error}}</p>{{/if}}
+  {{/if}}
+</form>

+ 2 - 1
server/views/partials/settings/apikey.hbs

@@ -12,6 +12,7 @@
     {{#if user.apikey}}
       <div class="clipboard small">
         <button 
+          type="button"
           aria-label="Copy" 
           hx-on:click="handleShortURLCopyLink(this);" 
           data-url="{{user.apikey}}"
@@ -37,7 +38,7 @@
     hx-target="#apikey-wrapper" 
     hx-swap="outerHTML"
   >
-    <button class="secondary">
+    <button type="button" class="secondary">
       <span>{{> icons/zap}}</span>
       <span>{{> icons/spinner}}</span>
       {{#if user.apikey}}Reg{{else}}G{{/if}}enerate key

+ 1 - 1
server/views/partials/settings/change_email.hbs

@@ -34,7 +34,7 @@
         {{#if errors.email}}<p class="error">{{errors.email}}</p>{{/if}}
       </label>
     </div>
-    <button class="primary" type="submit">
+    <button type="submit" class="primary">
       <span>{{> icons/reload}}</span>
       <span>{{> icons/spinner}}</span>
       Update

+ 1 - 1
server/views/partials/settings/change_password.hbs

@@ -34,7 +34,7 @@
         {{#if errors.newpassword}}<p class="error">{{errors.newpassword}}</p>{{/if}}
       </label>
     </div>
-    <button class="primary" type="submit">
+    <button type="submit" class="primary">
       <span>{{> icons/reload}}</span>
       <span>{{> icons/spinner}}</span>
       Update

+ 1 - 1
server/views/partials/settings/delete_account.hbs

@@ -27,7 +27,7 @@
           {{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
         </label>
       </div>
-      <button class="danger" type="submit">
+      <button type="submit" class="danger">
         <span>{{> icons/trash}}</span>
         <span>{{> icons/spinner}}</span>
         Delete

+ 2 - 1
server/views/partials/settings/domain/delete.hbs

@@ -4,8 +4,9 @@
     Are you sure do you want to delete the domain &quot;<b>{{address}}</b>&quot;?
   </p>
   <div class="buttons">
-    <button hx-on:click="closeDialog()">Cancel</button>
+    <button type="button" hx-on:click="closeDialog()">Cancel</button>
     <button 
+      type="button"
       class="danger confirm" 
       hx-delete="/api/domains/{id}" 
       hx-ext="path-params" 

+ 2 - 2
server/views/partials/settings/domain/delete_success.hbs

@@ -1,12 +1,12 @@
 <div class="content">
   <div class="icon success">
-    <svg class="check" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>
+    {{> icons/check}}
   </div>
   <p>
     Your domain <b>"{{address}}"</b> has been deleted.
   </p>
   <div class="buttons">
-    <button hx-on:click="closeDialog()">Close</button>
+    <button type="button" hx-on:click="closeDialog()">Close</button>
   </div>
 </div>
 {{> settings/domain/table}}

+ 1 - 1
server/views/partials/settings/domain/dialog.hbs

@@ -2,7 +2,7 @@
   <div class="box">
     <div class="content-wrapper"></div>
     <div class="loading">
-      <svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
+      {{> icons/spinner}}
     </div>
   </div>
 </div>

+ 1 - 0
server/views/partials/settings/domain/index.hbs

@@ -13,6 +13,7 @@
 {{> settings/domain/table}}
 <div class="add-domain-wrapper">
   <button
+    type="button"
     class="secondary show-domain-form"
     hx-indicator=".add-domain-wrapper"
     hx-get="/add-domain-form"

+ 1 - 0
server/views/partials/settings/domain/table.hbs

@@ -22,6 +22,7 @@
           </td>
           <td class="actions">
             <button 
+              type="button"
               class="action delete" 
               hx-on:click='openDialog("domain-dialog")' 
               hx-get="/confirm-domain-delete" 

+ 3 - 2
server/views/partials/shortener.hbs

@@ -3,6 +3,7 @@
     {{#if link}}
       <div class="clipboard">
         <button 
+          type="button"
           aria-label="Copy" 
           hx-on:click="handleShortURLCopyLink(this);" 
           data-url="{{url}}"
@@ -43,8 +44,8 @@
         hx-preserve="true"
       />
       <button class="submit">
-        <svg class="send" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="m2 21 21-9L2 3v7l15 2-15 2z"/></svg>
-        <svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
+        {{> icons/send}}
+        {{> icons/spinner}}
       </button>
       {{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
       {{#unless errors}}

+ 100 - 0
server/views/partials/stats.hbs

@@ -0,0 +1,100 @@
+{{#if error}}
+  <div class="stats-error">
+    <p>{{> icons/x}} {{error}}</p>
+    <div class="stats-back-to-home">
+      <a class="back-to-home" href="/">
+        ← Back to homepage
+      </a>
+    </div>
+  </div>
+{{else}}
+  <div class="stats-info">
+    <h2>
+      Stats for:
+      <a href="{{link.link.url}}" title="Short link">
+        {{link.link.link}}
+      </a>
+    </h2>
+    <p>{{link.target}}</p>
+  </div>
+  <main id="stats">
+    <div class="stats-head">
+      <p>
+        Total views: <span class="total-number">{{link.visit_count}}</span>
+      </p>
+      <nav class="stats-nav">
+        <button type="button" class="nav" data-period="year" onclick="changeStatsPeriod(event)">Year</button>
+        <button type="button" class="nav" data-period="month" onclick="changeStatsPeriod(event)">Month</button>
+        <button type="button" class="nav" data-period="week" onclick="changeStatsPeriod(event)">Week</button>
+        <button type="button" class="nav" data-period="day" onclick="changeStatsPeriod(event)" disabled="true">Day</button>
+      </nav>
+    </div>
+
+    <div class="stats-period">
+      <h2 data-period="day"><span class="total-in-period">{{stats.lastDay.total}}</span> tracked visits in the last day.</h2>
+      <h2 class="hidden" data-period="week"><span class="total-in-period">{{stats.lastWeek.total}}</span> tracked visits in the last week.</h2>
+      <h2 class="hidden" data-period="month"><span class="total-in-period">{{stats.lastMonth.total}}</span> tracked visits in the last month.</h2>
+      <h2 class="hidden" data-period="year"><span class="total-in-period">{{stats.lastYear.total}}</span> tracked visits in the last year.</h2>
+      <p class="last-update">Last update at <span class="last-update-value" data-date="{{stats.updatedAt}}"></span>.</p>
+      <canvas class="visits" height="350" data-period="day" data-data="{{json stats.lastDay.views}}"></canvas>
+      <canvas class="visits hidden" height="350" data-period="week" data-data="{{json stats.lastWeek.views}}"></canvas>
+      <canvas class="visits hidden" height="350" data-period="month" data-data="{{json stats.lastMonth.views}}"></canvas>
+      <canvas class="visits hidden" height="350" data-period="year" data-data="{{json stats.lastYear.views}}"></canvas>
+      <hr />
+      <div class="stats-columns-wrapper">
+        <div>
+          <h2>Referrers.</h2>
+          <canvas class="referrers" height="325" data-period="day" data-data="{{json stats.lastDay.stats.referrer}}"></canvas>
+          <canvas class="referrers hidden" height="325" data-period="week" data-data="{{json stats.lastWeek.stats.referrer}}"></canvas>
+          <canvas class="referrers hidden" height="325" data-period="month" data-data="{{json stats.lastMonth.stats.referrer}}"></canvas>
+          <canvas class="referrers hidden" height="325" data-period="year" data-data="{{json stats.lastYear.stats.referrer}}"></canvas>
+        </div>
+        <div>
+          <h2>Browsers.</h2>
+          <canvas class="browsers" height="350" data-period="day" data-data="{{json stats.lastDay.stats.browser}}"></canvas>
+          <canvas class="browsers hidden" height="350" data-period="week" data-data="{{json stats.lastWeek.stats.browser}}"></canvas>
+          <canvas class="browsers hidden" height="350" data-period="month" data-data="{{json stats.lastMonth.stats.browser}}"></canvas>
+          <canvas class="browsers hidden" height="350" data-period="year" data-data="{{json stats.lastYear.stats.browser}}"></canvas>
+        </div>
+      </div>
+      <hr />
+      <div class="stats-columns-wrapper">
+        <div>
+          <h2>Countries.</h2>
+          <div id="map-tooltip"></div>
+          <svg 
+            class="map" 
+            xmlns="http://www.w3.org/2000/svg" 
+            aria-label="world map" 
+            viewBox="{{map.viewBox}}"
+            data-day="{{json stats.lastDay.stats.country}}"
+            data-week="{{json stats.lastWeek.stats.country}}"
+            data-month="{{json stats.lastMonth.stats.country}}"
+            data-year="{{json stats.lastYear.stats.country}}"
+            onmouseout="mapTooltipHoverOut()"
+            onmousemove="mapTooltipHoverOver(event)"
+            onpointerdown="mapTooltipHoverOver(event)"
+            onpointerup="mapTooltipHoverOut()"
+          >
+            {{#each map.layers}}
+              <path data-id="{{id}}" aria-label="{{name}}" d="{{d}}"></path>
+            {{/each}}
+          </svg>
+        </div>
+        <div>
+          <h2>Operation systems.</h2>
+          <canvas class="os" height="350" data-period="day" data-data="{{json stats.lastDay.stats.os}}"></canvas>
+          <canvas class="os hidden" height="350" data-period="week" data-data="{{json stats.lastWeek.stats.os}}"></canvas>
+          <canvas class="os hidden" height="350" data-period="month" data-data="{{json stats.lastMonth.stats.os}}"></canvas>
+          <canvas class="os hidden" height="350" data-period="year" data-data="{{json stats.lastYear.stats.os}}"></canvas>
+        </div>
+      </div>
+    </div>
+  </main>
+
+  <div class="stats-back-to-home">
+    <a class="back-to-home" href="/">
+      ← Back to homepage
+    </a>
+  </div>
+{{/if}}

+ 11 - 0
server/views/protected.hbs

@@ -0,0 +1,11 @@
+{{> header}}
+<section id="protected" class="section-container">
+  <h2>
+    Protected link.
+  </h2>
+  <p>
+    Enter the password to be redirected to the link.
+  </p>
+  {{> protected/form}}
+</section>
+{{> footer}}

+ 13 - 0
server/views/report.hbs

@@ -0,0 +1,13 @@
+{{> header}}
+<section id="report" class="section-container">
+  <h2>
+    Report abuse.
+  </h2>
+  <p>
+    Report abuses, malware and phishing links to the email address below
+    or use the form. We will review as soon as we can.
+  </p>
+  {{> report/email}}
+  {{> report/form}}
+</section>
+{{> footer}}

+ 12 - 0
server/views/reset_password.hbs

@@ -0,0 +1,12 @@
+{{> header}}
+<section id="reset-password" class="section-container">
+  <h2>
+    Reset password.
+  </h2>
+  <p>
+    If you forgot you password you can use the form below to get a reset
+    password link.
+  </p>
+  {{> reset_password/form}}
+</section>
+{{> footer}}

+ 15 - 0
server/views/reset_password_result.hbs

@@ -0,0 +1,15 @@
+{{> header}}
+<section id="reset-password-token" class="section-container verify-page">
+  {{#if token_verified}}
+    <h2 hx-get="/settings" hx-trigger="load delay:2s" hx-target="body" hx-push-url="/settings">
+      Welcome back. Change your password from the settings page. Redirecting...
+    </h2>
+  {{else}}
+    <h2>
+      {{> icons/x}}
+      Password token is invalid. Please try again.
+    </h2>
+    <a href="/reset-password" title="Reset password">Reset password →</a>
+  {{/if}}
+</section>
+{{> footer}}

+ 1 - 1
server/views/settings.hbs

@@ -1,5 +1,5 @@
 {{> header}}
-<section id="settings">
+<section id="settings" class="section-container">
   <h1 class="settings-welcome">
     Welcome, <span>{{user.email}}</span>.
   </h1>

+ 24 - 0
server/views/stats.hbs

@@ -0,0 +1,24 @@
+{{> header}}
+<section
+  id="stats-section"
+  class="section-container"
+  hx-get="/api/links/{id}/stats"
+  hx-swap="innerHTML"
+  hx-trigger="load once"
+  hx-vals='js:{ id: getQueryParams().id || "" }' 
+  hx-ext="path-params"
+  hx-on::after-swap="
+    trimText('.stats-info p', 80);
+    formatDateHour('#stats .last-update-value');
+    createCharts();
+  "
+>
+  <div class="loading-stats">
+    {{> icons/spinner}}
+    Loading stats...
+  </div>
+</section>
+{{> footer}}
+{{#extend "scripts"}}
+  <script src="/libs/chart.min.js"></script>
+{{/extend}}

+ 50 - 0
server/views/terms.hbs

@@ -0,0 +1,50 @@
+{{> header}}
+<section id="terms" class="section-container">
+  <h3>{{default_domain}} Terms of Service</h3>
+  <p>
+    By accessing the website at
+    <a href="https://{{default_domain}}">https://{{default_domain}}</a>, you are agreeing to be bound by these terms of service, all applicable
+    laws and regulations, and agree that you are responsible for compliance
+    with any applicable local laws. If you do not agree with any of these
+    terms, you are prohibited from using or accessing this site. The
+    materials contained in this website are protected by applicable
+    copyright and trademark law.
+  </p>
+  <p>
+    In no event shall {{site_name}} or its suppliers be
+    liable for any damages (including, without limitation, damages for loss
+    of data or profit, or due to business interruption) arising out of the
+    use or inability to use the materials on
+    {{default_domain}} website, even if
+    {{site_name}} or a {{site_name}}
+    authorized representative has been notified orally or in writing of the
+    possibility of such damage. Because some jurisdictions do not allow
+    limitations on implied warranties, or limitations of liability for
+    consequential or incidental damages, these limitations may not apply to
+    you.
+  </p>
+  <p>
+    The materials appearing on {{site_name}} website could
+    include technical, typographical, or photographic errors.
+    {{site_name}} does not warrant that any of the
+    materials on its website are accurate, complete or current.
+    {{site_name}} may make changes to the materials
+    contained on its website at any time without notice. However
+    {{site_name}} does not make any commitment to update
+    the materials.
+  </p>
+  <p>
+    {{site_name}} has not reviewed all of the sites linked
+    to its website and is not responsible for the contents of any such
+    linked site. The inclusion of any link does not imply endorsement by
+    {{site_name}} of the site. Use of any such linked
+    website is at the "user's" own risk.
+  </p>
+  <p>
+    {{site_name}} may revise these terms of service for
+    its website at any time without notice. By using this website you are
+    agreeing to be bound by the then current version of these terms of
+    service.
+  </p>
+</section>
+{{> footer}}

+ 6 - 0
server/views/url_info.hbs

@@ -0,0 +1,6 @@
+{{> header}}
+<section id="url-info" class="section-container">
+  <h3>Target for <b>{{link}}</b>:</h3>
+  <p>{{target}}</p>
+</section>
+{{> footer}}

+ 15 - 0
server/views/verify.hbs

@@ -0,0 +1,15 @@
+{{> header}}
+<section id="verify" class="section-container verify-page">
+  {{#if token_verified}}
+    <h2 hx-get="/" hx-trigger="load delay:2s" hx-target="body" hx-push-url="/">
+      Your account has been verified. Redirecting to homepage...
+    </h2>
+  {{else}}
+    <h2>
+      {{> icons/x}}
+      Invalid verification. Please try again.
+    </h2>
+    <a href="/login" title="Log in or sign up">Log in / sign up →</a>
+  {{/if}}
+</section>
+{{> footer}}

+ 19 - 0
server/views/verify_change_email.hbs

@@ -0,0 +1,19 @@
+{{> header}}
+<section id="verify-change-email" class="section-container verify-page">
+  {{#if token_verified}}
+    <h2 hx-get="/" hx-trigger="load delay:2s" hx-target="body" hx-push-url="/">
+      Email address is verified. Redirecting to homepage...
+    </h2>
+  {{else}}
+    <h2>
+      {{> icons/x}}
+      Couldn't verify the email address. Please try again.
+    </h2>
+    {{#if user}}
+      <a href="/settings" title="Settings">Settings →</a>
+    {{else}}
+      <a href="/login" title="Log in or sign up">Log in / sign up →</a>
+    {{/if}}
+  {{/if}}
+</section>
+{{> footer}}

+ 457 - 123
static/css/styles.css

@@ -83,14 +83,19 @@ hr {
   background-color: hsl(200, 20%, 92%);
 }
 
-a {
+span.bold { font-weight: bold; }
+span.underline { border-bottom: 2px dotted #999; }
+
+a,
+button.link {
   color: var(--color-primary);
   border-bottom: 1px dotted transparent;
   text-decoration: none;
   transition: all 0.2s ease-out;
 }
 
-a:hover {
+a:hover,
+button.link:hover {
   border-bottom-color: var(--color-primary);
 }
 
@@ -191,9 +196,13 @@ button.success:hover {
   box-shadow: 0 6px 15px var(--button-bg-success-box-shadow-color);
 }
 
+a.button:disabled,
 button:disabled { cursor: default; }
+a.button:disabled:hover,
 button:disabled:hover { transform: none; }
 
+a.button svg.with-text,
+a.button span svg,
 button svg.with-text,
 button span svg {
   width: 16px;
@@ -203,6 +212,7 @@ button span svg {
   stroke-width: 2;
 }
 
+a.button.action,
 button.action {
   padding: 5px;
   width: 24px;
@@ -210,68 +220,82 @@ button.action {
   box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12);
 }
 
+a.button.action:disabled,
 button.action:disabled {
   background: none;
   box-shadow: none;
 }
 
+a.button.action svg,
 button.action svg {
   width: 100%;
   margin-right: 0;
 }
 
+a.button.action.delete,
 button.action.delete {
   background: hsl(0, 100%, 96%);
 }
 
+a.button.action.delete svg,
 button.action.delete svg {
   stroke-width: 2;
   stroke: hsl(0, 100%, 69%);
 }
 
+a.button.action.edit,
 button.action.edit {
   background: hsl(46, 100%, 94%);
 }
 
+a.button.action.edit svg,
 button.action.edit svg {
   stroke-width: 2.5;
   stroke: hsl(46, 90%, 50%);
 }
 
+a.button.action.qrcode,
 button.action.qrcode {
   background: hsl(0, 0%, 94%);
 }
 
+a.button.action.qrcode svg,
 button.action.qrcode svg {
   fill: hsl(0, 0%, 35%);
   stroke: none;
 }
 
+a.button.action.stats,
 button.action.stats {
   background: hsl(260, 100%, 96%);
 }
 
+a.button.action.stats svg,
 button.action.stats svg {
   stroke-width: 2.5;
   stroke: hsl(260, 100%, 69%);
 }
 
+a.button.action.ban,
 button.action.ban {
   background: hsl(10, 100%, 96%);
 }
 
+a.button.action.ban svg,
 button.action.ban svg {
   stroke-width: 2;
   stroke: hsl(10, 100%, 40%);
 }
 
+a.button.action.password sv,
 button.action.password svg,
+a.button.action.banned svg,
 button.action.banned svg {
   stroke-width: 2.5;
   stroke: #bbb;
 }
 
-button.table-nav {
+button.nav {
   box-sizing: border-box;
   width: auto;
   height: 28px;
@@ -290,7 +314,7 @@ button.table-nav {
   cursor: pointer;
 }
 
-button.table-nav:disabled {
+button.nav:disabled {
   background-color: #f6f6f6;
   box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
   opacity: 0.9;
@@ -298,15 +322,47 @@ button.table-nav:disabled {
   cursor: default;
 }
 
-button.table-nav svg {
+button.nav svg {
   width: 14px;
   height: auto;
 }
 
-button.table-nav svg { stroke-width: 2.5; }
+button.nav svg { stroke-width: 2.5; }
+
+button.nav:hover { transform: translateY(-2px); }
+button.nav:disabled:hover { transform: none; }
+
+button.link {
+  position: relative;
+  width: auto;
+  height: auto;
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  padding: 0 0 2px 0;
+  font-size: 1rem;
+  font-weight: normal;
+  border-radius: 0;
+  text-align: left;
+  line-height: normal;
+  word-break: normal;
+  cursor: pointer;
+  background: none;
+  box-shadow: none;
+}
+
+button.link:hover {
+  box-shadow: none;
+  transform: none;
+}
 
-button.table-nav:hover { transform: translateY(-2px); }
-button.table-nav:disabled:hover { transform: none; }
+button.link span {
+  height: 1rem;
+}
+
+button.link svg {
+  stroke: var(--color-primary);
+}
 
 svg.spinner {
   animation: spin 1s linear infinite, fadein 0.3s ease-in-out;
@@ -459,6 +515,8 @@ label.checkbox input[type="checkbox"] {
 
 p.error,
 p.success {
+  display: flex;
+  align-items: center;
   font-weight: normal;
   animation: fadein 0.3s ease-in-out;
 }
@@ -755,6 +813,15 @@ table tr.loading-placeholder td {
   flex-direction: column;
 }
 
+.section-container {
+  max-width: 90%;
+  flex: 1 1 auto;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  margin-top: 1rem;
+}
+
 /* LOGIN & SIGNUP */
 
 form#login-signup {
@@ -797,8 +864,8 @@ form#login-signup a.forgot-password {
 }
 
 form#login-signup svg.spinner {  display: none; }
+form#login-signup.htmx-request:not(.signup) .login svg { display: none; } 
 form#login-signup.htmx-request:not(.signup) .login svg.spinner { display: block; } 
-form#login-signup.htmx-request:not(.signup) .login svg.icon { display: none; } 
 form#login-signup.htmx-request.signup .signup svg.spinner { display: block; } 
 form#login-signup.htmx-request.signup .signup svg.icon { display: none; } 
 form#login-signup.htmx-request .error { opacity: 0; }
@@ -1158,7 +1225,9 @@ main form label#advanced input {
 #links-table-wrapper table .short-link { flex: 3 3 0; }
 #links-table-wrapper table .views { flex: 1 1 0; justify-content: flex-end; }
 #links-table-wrapper table .actions { flex: 3 3 0; justify-content: flex-end; overflow: visible; }
+#links-table-wrapper table .actions a.button,
 #links-table-wrapper table .actions button { margin-right: 0.5rem; }
+#links-table-wrapper table .actions a.button:last-child,
 #links-table-wrapper table .actions button:last-child { margin-right: 0; }
 
 #links-table-wrapper table td.original-url,
@@ -1231,10 +1300,10 @@ main form label#advanced input {
   align-items: center;
 }
 
-#links-table-wrapper table button.table-nav { margin-right: 0.75rem; }
-#links-table-wrapper table button.table-nav:last-child { margin-right: 0; }
+#links-table-wrapper table button.nav { margin-right: 0.75rem; }
+#links-table-wrapper table button.nav:last-child { margin-right: 0; }
 
-#links-table-wrapper table .table-nav-divider {
+#links-table-wrapper table .nav-divider {
   height: 20px;
   width: 1px;
   opacity: 0.4;
@@ -1346,116 +1415,6 @@ main form label#advanced input {
 .dialog .ban-checklist label { margin: 1rem 1.5rem 1rem 0; }
 .dialog .ban-checklist label:last-child { margin-right: 0; }
 
-/* SETTINGS */
-
-#settings {
-  width: 600px;
-  max-width: 90%;
-  flex: 1 1 auto;
-  display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-  margin-top: 2rem;
-}
-
-h1.settings-welcome {
-  font-size: 28px;
-  font-weight: 300;
-}
-
-h1.settings-welcome span {
-  border-bottom: 2px dotted #999;
-  padding-bottom: 2px;
-  font-weight: normal;
-}
-
-/* SETTINGS - DOMAIN */
-
-#domains-table { margin-top: 1rem; }
-#domains-table .domain { flex: 2 2 0; }
-#domains-table .homepage { flex: 2 2 0; }
-#domains-table .actions { flex: 1 1 0; justify-content: flex-end; padding-right: 1rem; }
-#domains-table .no-entry { flex: 1 1 0; opacity: 0.8; }
-
-.add-domain-wrapper {
-  width: 100%;
-  display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-  margin: 1.5rem 0 2rem;
-}
-
-.add-domain-wrapper > .spinner { 
-  width: 20px; 
-  display: none; 
-  margin: 1rem 0 0 1rem; 
-}
-.add-domain-wrapper.htmx-request > button { display: none; }
-.add-domain-wrapper.htmx-request > .spinner { display: block; }
-
-form#add-domain { margin-top: 1rem; }
-form#add-domain .buttons-wrapper { display: flex; }
-form#add-domain button { margin-right: 1rem }
-form#add-domain .spinner { width: 20px; display: none; }
-form#add-domain.htmx-request .buttons-wrapper { display: none; }
-form#add-domain.htmx-request .spinner { display: block; }
-form#add-domain .error { font-size: 0.85rem; }
-
-/* SETTINGS - API */
-
-#apikey-wrapper { margin-bottom: 1.5rem; }
-
-#apikey {
-  display: flex;
-  align-items: center;
-  margin-bottom: 1rem;
-}
-
-#apikey p {
-  font-weight: bold;
-  border-bottom: 1px dotted #999;
-  transition: opacity 0.2s ease-in-out;
-  cursor: pointer;
-}
-
-#apikey p:hover {
-  opacity: 0.8;
-}
-
-form#generate-apikey .spinner { display: none; }
-form#generate-apikey.htmx-request svg { display: none; }
-form#generate-apikey.htmx-request .spinner { display: block; }
-
-/* SETTINGS - CHANGE PASSWORD */
-
-#change-password-wrapper { margin-bottom: 1.5rem; }
-
-form#change-password { margin-top: 1.5rem; }
-form#change-password button { margin-top: 1rem; }
-form#change-password .spinner { display: none; }
-form#change-password.htmx-request svg { display: none; }
-form#change-password.htmx-request .spinner { display: block; }
-
-/* SETTINGS - CHANGE EMAIL */
-
-#change-email-wrapper { margin-bottom: 1.5rem; }
-
-form#change-email { margin-top: 1.5rem; }
-form#change-email button { margin-top: 1rem; }
-form#change-email .spinner { display: none; }
-form#change-email.htmx-request svg { display: none; }
-form#change-email.htmx-request .spinner { display: block; }
-
-
-/* SETTINGS - DELETE ACCOUNT */
-
-#delete-account-wrapper { margin-bottom: 1.5rem; }
-
-form#delete-account { margin-top: 1.5rem; }
-form#delete-account button { margin-top: 1rem; }
-form#delete-account .spinner { display: none; }
-form#delete-account.htmx-request svg { display: none; }
-form#delete-account.htmx-request .spinner { display: block; }
 
 /* INTRO */
 
@@ -1632,4 +1591,379 @@ footer {
   padding: 1rem 0;
   font-size: 13px;
   text-align: center;
-}
+}
+
+/* SETTINGS */
+
+#settings {
+  width: 600px;
+  max-width: 90%;
+}
+
+h1.settings-welcome {
+  font-size: 28px;
+  font-weight: 300;
+}
+
+h1.settings-welcome span {
+  border-bottom: 2px dotted #999;
+  padding-bottom: 2px;
+  font-weight: normal;
+}
+
+/* SETTINGS - DOMAIN */
+
+#domains-table { margin-top: 1rem; }
+#domains-table .domain { flex: 2 2 0; }
+#domains-table .homepage { flex: 2 2 0; }
+#domains-table .actions { flex: 1 1 0; justify-content: flex-end; padding-right: 1rem; }
+#domains-table .no-entry { flex: 1 1 0; opacity: 0.8; }
+
+.add-domain-wrapper {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  margin: 1.5rem 0 2rem;
+}
+
+.add-domain-wrapper > .spinner { 
+  width: 20px; 
+  display: none; 
+  margin: 1rem 0 0 1rem; 
+}
+.add-domain-wrapper.htmx-request > button { display: none; }
+.add-domain-wrapper.htmx-request > .spinner { display: block; }
+
+form#add-domain { margin-top: 1rem; }
+form#add-domain .buttons-wrapper { display: flex; }
+form#add-domain button { margin-right: 1rem }
+form#add-domain .spinner { width: 20px; display: none; }
+form#add-domain.htmx-request .buttons-wrapper { display: none; }
+form#add-domain.htmx-request .spinner { display: block; }
+form#add-domain .error { font-size: 0.85rem; }
+
+/* SETTINGS - API */
+
+#apikey-wrapper { margin-bottom: 1.5rem; }
+
+#apikey {
+  display: flex;
+  align-items: center;
+  margin-bottom: 1rem;
+}
+
+#apikey p {
+  font-weight: bold;
+  border-bottom: 1px dotted #999;
+  transition: opacity 0.2s ease-in-out;
+  cursor: pointer;
+}
+
+#apikey p:hover {
+  opacity: 0.8;
+}
+
+form#generate-apikey .spinner { display: none; }
+form#generate-apikey.htmx-request svg { display: none; }
+form#generate-apikey.htmx-request .spinner { display: block; }
+
+/* SETTINGS - CHANGE PASSWORD */
+
+#change-password-wrapper { margin-bottom: 1.5rem; }
+
+form#change-password { margin-top: 1.5rem; }
+form#change-password button { margin-top: 1rem; }
+form#change-password .spinner { display: none; }
+form#change-password.htmx-request svg { display: none; }
+form#change-password.htmx-request .spinner { display: block; }
+
+/* SETTINGS - CHANGE EMAIL */
+
+#change-email-wrapper { margin-bottom: 1.5rem; }
+
+form#change-email { margin-top: 1.5rem; }
+form#change-email button { margin-top: 1rem; }
+form#change-email .spinner { display: none; }
+form#change-email.htmx-request svg { display: none; }
+form#change-email.htmx-request .spinner { display: block; }
+
+
+/* SETTINGS - DELETE ACCOUNT */
+
+#delete-account-wrapper { margin-bottom: 1.5rem; }
+
+form#delete-account { margin-top: 1.5rem; }
+form#delete-account button { margin-top: 1rem; }
+form#delete-account .spinner { display: none; }
+form#delete-account.htmx-request svg { display: none; }
+form#delete-account.htmx-request .spinner { display: block; }
+
+/* STATS */
+
+#stats-section {
+  width: 1200px;
+  max-width: 95%;
+}
+
+.loading-stats { 
+  width: 100%;
+  flex: 1 1 0;
+  margin-top: -5rem;
+  display: flex; 
+  align-items: center; 
+  justify-content: center;
+}
+.loading-stats .spinner {
+  width: 1.25rem;
+  margin-right: 0.5rem;
+}
+
+.stats-info {
+  width: 100%;
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+}
+
+.stats-info h2 { font-weight: 300; font-size: 24px; }
+.stats-info p { font-size: 14px; }
+.stats-info h2,
+.stats-info p { margin: 0 }
+
+#stats {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  background-color: white;
+  border-radius: 12px;
+  box-shadow: 0 6px 15px hsla(200, 20%, 70%, 0.3);
+  overflow: hidden;
+  padding: 0;
+}
+
+.stats-head {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  background-color: hsl(200, 12%, 95%);
+  justify-content: space-between;
+  padding: 0.75rem 1.5rem;
+}
+
+.total-number { font-weight: bold; }
+
+.stats-nav { display: flex; align-items: center; }
+
+.stats-nav button { margin-right: 0.75rem; }
+.stats-nav button:last-child { margin-right: 0; }
+
+.stats-period {
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  padding: 0.75rem 1.5rem;
+}
+
+.stats-period h2 {
+  font-size: 24px;
+  font-weight: 300;
+  margin: 1rem 0 0;
+}
+
+.stats-period span.total-in-period {
+  font-weight: bold;
+  border-bottom: 1px dotted hsl(200, 35%, 65%);
+}
+
+p.last-update {
+  font-size: 14px;
+  color: hsl(200, 14%, 60%);
+  margin: 0.75rem 0 0;
+}
+
+#stats canvas {
+  width: 100%;
+  margin: 2rem 0;
+}
+
+.stats-columns-wrapper {
+  display: flex;
+  align-items: flex-start;
+}
+
+.stats-columns-wrapper > div {
+  flex: 1 1 50%;
+}
+
+svg.map path {
+  fill: hsl(200, 15%, 92%);
+  stroke: #fff;
+  transition: all 0.1s ease-in-out;
+}
+
+svg.map path.color-1 { fill: hsl(261, 46%, 90%); }
+svg.map path.color-2 { fill: hsl(261, 46%, 86%); }
+svg.map path.color-3 { fill: hsl(261, 46%, 82%); }
+svg.map path.color-4 { fill: hsl(261, 46%, 76%); }
+svg.map path.color-5 { fill: hsl(261, 46%, 72%); }
+svg.map path.color-6 { fill: hsl(261, 46%, 68%); }
+svg.map path.active { stroke: hsl(261, 46%, 50%); stroke-width: 1.5; }
+
+#map-tooltip {
+  position: fixed;
+}
+
+#map-tooltip.visible::before,
+#map-tooltip.visible::after {
+  display: block !important;
+}
+
+#map-tooltip:before {
+  border-top-color: rgba(255, 255, 255, 0.95);
+}
+
+#map-tooltip:after {
+  box-shadow: 0 1em 2em -0.5em rgba(0, 0, 0, 0.15);
+  background: rgba(255, 255, 255, 0.95);
+  color: #333;
+}
+
+.stats-back-to-home {
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  margin: 2rem 0;
+}
+
+.stats-error {
+  width: 100%;
+  flex: 1 1 auto;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+}
+
+.stats-error p { margin-top: -3rem; display: flex; align-items: center; }
+.stats-error p svg { width: 1.2rem; margin: 0 0.5rem 0.1rem 0; }
+.stats-error .stats-back-to-home { margin-top: 0 }
+
+/* 404 - NOT FOUND */
+
+#notfound {
+  width: 800px;
+  align-items: center;
+}
+
+#notfound h2 { 
+  font-size: 28px;
+  font-weight: 300;
+}
+
+/* BANNED */
+
+#banned { width: 1200px; align-items: center; }
+#banned h2 { font-weight: normal; }
+#banned h4 { font-weight: normal; margin: 0; }
+
+/* REPORT */
+
+#report { width: 600px; }
+
+#report form {
+  display: flex;
+  flex-direction: column;
+  margin-top: 2rem;
+}
+
+#report form .inputs-wrapper { 
+  display: flex;
+  align-items: flex-end;
+}
+
+#report form button { margin: 0 0 0.2rem 1rem; }
+#report form .spinner { display: none; }
+#report form.htmx-request svg { display: none; }
+#report form.htmx-request .spinner { display: block; }
+
+#report-email .spinner { display: none; }
+#report-email .htmx-request svg { display: none; }
+#report-email .htmx-request .spinner { display: block; }
+
+.eye-icon svg { stroke-width: 0.5; }
+
+/* RESET PASSWORD */
+
+#reset-password form {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+#reset-password form .inputs-wrapper { 
+  display: flex;
+  align-items: flex-end;
+  margin-top: 2rem;
+  
+}
+
+#reset-password form label { flex: 0 0 280px; }
+#reset-password form label input { width: 100%; }
+#reset-password form button { margin: 0 0 0.2rem 1rem; }
+#reset-password .spinner { display: none; }
+#reset-password .htmx-request svg { display: none; }
+#reset-password .htmx-request .spinner { display: block; }
+
+/* VERIFY USER */
+/* VERIFY CHANGE EMAIL */
+/* RESET PASSWORD TOKEN */
+
+.verify-page {
+  width: 600px;
+  align-items: center;
+}
+
+.verify-page h2,
+.verify-page h3 { 
+  display: flex;
+  align-items: center;
+  text-align: center;
+  font-weight: normal;
+}
+
+.verify-page h2 svg, 
+.verify-page h3 svg {
+  width: 1.15em;
+  height: auto;
+  margin-right: 0.5rem;
+}
+
+/* URL INFO */
+
+#url-info {
+  width: 1200px;
+  align-items: center;
+  text-align: center;
+}
+
+#url-info h3 { font-weight: normal; margin: 0; }
+
+/* PROTECTED */
+
+#protected { width: 600px; }
+
+#protected form { width: 100%; margin-top: 1rem; }
+#protected form .inputs-wrapper {  width: 100%; display: flex; align-items: flex-end; }
+#protected form label { flex: 0 0 280px; }
+#protected form label input { width: 100%; }
+#protected form button { margin: 0 0 0.2rem 1rem; }
+#protected form .spinner { display: none; }
+#protected form.htmx-request svg { display: none; }
+#protected form.htmx-request .spinner { display: block; }
+
+/* TERMS */
+
+#terms { width: 600px; }

Datei-Diff unterdrückt, da er zu groß ist
+ 12 - 0
static/libs/chart.min.js


+ 478 - 0
static/scripts/main.js

@@ -52,6 +52,38 @@ function closest(selector, elm) {
   return null;
 };
 
+// get url query param
+function getQueryParams() {
+  const search = window.location.search.replace("?", "");
+  const query = {};
+  search.split("&").map(q => {
+    const keyvalue = q.split("=");
+    query[keyvalue[0]] = keyvalue[1];
+  });
+  return query;
+}
+
+// trim text
+function trimText(selector, length) {
+  const element = document.querySelector(selector);
+  if (!element) return;
+  let text = element.textContent;
+  if (typeof text !== "string") return;
+  text = text.trim();
+  if (text.length > length) {
+    element.textContent = text.split("").slice(0, length).join("") + "...";
+  }
+}
+
+function formatDateHour(selector) {
+  const element = document.querySelector(selector);
+  if (!element) return;
+  const dateString = element.dataset.date;
+  if (!dateString) return;
+  const date = new Date(dateString);
+  element.textContent = date.getHours() + ":" + date.getMinutes();
+}
+
 // show QR code
 function handleQRCode(element) {
   const dialog = document.querySelector("#link-dialog");
@@ -175,4 +207,450 @@ function resetLinkNav() {
   document.querySelectorAll('table .nav .limit button').forEach(b => {
     b.disabled = b.textContent === limit.toString();
   });
+}
+
+// create views chart label
+function createViewsChartLabel(ctx) {
+  const period = ctx.dataset.period;
+  let labels = [];
+
+  if (period === "day") {
+    const nowHour = new Date().getHours();
+    for (let i = 23; i >= 0; --i) {
+      let h = nowHour - i;
+      if (h < 0) h = 24 + h;
+      labels.push(`${Math.floor(h)}:00`);
+    }
+  }
+
+  if (period === "week") {
+    const nowDay = new Date().getDate();
+    for (let i = 6; i >= 0; --i) {
+      const date = new Date(new Date().setDate(nowDay - i));
+      labels.push(`${date.getDate()} ${date.toLocaleString("default",{month:"short"})}`);
+    }
+  }
+
+  if (period === "month") {
+    const nowDay = new Date().getDate();  
+    for (let i = 29; i >= 0; --i) {
+      const date = new Date(new Date().setDate(nowDay - i));
+      labels.push(`${date.getDate()} ${date.toLocaleString("default",{month:"short"})}`);
+    }
+  }
+
+  if (period === "year") {
+    const nowMonth = new Date().getMonth();  
+    for (let i = 11; i >= 0; --i) {
+      const date = new Date(new Date().setMonth(nowMonth - i));
+      labels.push(`${date.toLocaleString("default",{month:"short"})} ${date.toLocaleString("default",{year:"numeric"})}`);
+    }
+  }
+
+  return labels;
+}
+
+// create views chart
+function createViewsChart() {
+  const canvases = document.querySelectorAll("canvas.visits");
+  if (!canvases || !canvases.length) return;
+
+  canvases.forEach(ctx => {
+    const data = JSON.parse(ctx.dataset.data);
+    const period = ctx.dataset.period;
+  
+    const labels = createViewsChartLabel(ctx);
+    const maxTicksLimitX = period === "year" ? 6 : period === "month" ? 15 : 12;
+  
+    const gradient = ctx.getContext("2d").createLinearGradient(0, 0, 0, 300);
+    gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");   
+    gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
+    
+    new Chart(ctx, {
+      type: "line",
+      data: {
+        labels: labels,
+        datasets: [{
+          label: "Views",
+          data,
+          tension: 0.3,
+  
+          elements: {
+            point: {
+              pointRadius: 0,
+              pointHoverRadius: 4
+            }
+          },
+          fill: {
+            target: "start",
+          },
+          backgroundColor: gradient,
+          borderColor: "rgb(179, 157, 219)",
+          borderWidth: 1,
+        }]
+      },
+      options: {
+        plugins: {
+          legend: {
+            display: false,
+          },
+          tooltip: {
+            backgroundColor: "rgba(255, 255, 255, 0.95)",
+            titleColor: "#333",
+            titleFont: { weight: "normal", size: 15 },
+            bodyFont: { weight: "normal", size: 16 },
+            bodyColor: "rgb(179, 157, 219)",
+            padding: 12,
+            cornerRadius: 2,
+            borderColor: "rgba(0, 0, 0, 0.1)",
+            borderWidth: 1,
+            displayColors: false,
+          }
+        },
+        responsive: true,
+        interaction: {
+          intersect: false,
+          usePointStyle: true,
+          mode: "index",
+        },
+        scales: {
+          y: {
+            grace: "10%",
+            beginAtZero: true,
+            ticks: {
+              maxTicksLimit: 5
+            }
+          },
+          x: {
+            ticks: {
+              maxTicksLimit: maxTicksLimitX,
+            }
+          }
+        }  
+      }
+    });
+
+    // reset the display: block style that chart.js applies automatically
+    ctx.style.display = "";
+  });
+}
+
+// beautify browser lables
+function beautifyBrowserName(name) {
+  if (name === "firefox") return "Firefox";
+  if (name === "chrome") return "Chrome";
+  if (name === "edge") return "Edge";
+  if (name === "opera") return "Opera";
+  if (name === "safari") return "Safari";
+  if (name === "other") return "Other";
+  if (name === "ie") return "IE";
+  return name;
+}
+
+// create browsers chart
+function createBrowsersChart() {
+  const canvases = document.querySelectorAll("canvas.browsers");
+  if (!canvases || !canvases.length) return;
+
+  canvases.forEach(ctx => {
+    const data = JSON.parse(ctx.dataset.data);
+    const period = ctx.dataset.period;
+
+    const gradient = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
+    const gradientHover = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
+    gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");   
+    gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
+    gradientHover.addColorStop(0, "rgba(179, 157, 219, 0.9)");   
+    gradientHover.addColorStop(1, "rgba(179, 157, 219, 0.4)");
+
+    new Chart(ctx, {
+      type: "bar",
+      data: {
+        labels: data.map(d => beautifyBrowserName(d.name)),
+        datasets: [{
+          label: "Views",
+          data: data.map(d => d.value),
+          backgroundColor: gradient,
+          borderColor: "rgba(179, 157, 219, 1)",
+          borderWidth: 1,
+          hoverBackgroundColor: gradientHover,
+          hoverBorderWidth: 2
+        }]
+      },
+      options: {
+        indexAxis: "y",
+        plugins: {
+          legend: {
+            display: false,
+          },
+          tooltip: {
+            backgroundColor: "rgba(255, 255, 255, 0.95)",
+            titleColor: "#333",
+            titleFont: { weight: "normal", size: 15 },
+            bodyFont: { weight: "normal", size: 16 },
+            bodyColor: "rgb(179, 157, 219)",
+            padding: 12,
+            cornerRadius: 2,
+            borderColor: "rgba(0, 0, 0, 0.1)",
+            borderWidth: 1,
+            displayColors: false,
+          }
+        },
+        responsive: true,
+        interaction: {
+          intersect: false,
+          mode: "index",
+          axis: "y"
+        },
+        scales: {
+          x: {
+            grace: "5%",
+            beginAtZero: true,
+            ticks: {
+              maxTicksLimit: 6,
+            }
+          }
+        }  
+      }
+    });
+
+    // reset the display: block style that chart.js applies automatically
+    ctx.style.display = "";
+  });
+}
+
+// create referrers chart
+function createReferrersChart() {
+  const canvases = document.querySelectorAll("canvas.referrers");
+  if (!canvases || !canvases.length) return;
+
+  canvases.forEach(ctx => {
+    const data = JSON.parse(ctx.dataset.data);
+    const period = ctx.dataset.period;
+    let max = Array.from(data).sort((a, b) => a.value > b.value ? -1 : 1)[0];
+
+    let tooltipEnabled = true;
+    let hoverBackgroundColor = "rgba(179, 157, 219, 1)";
+    let hoverBorderWidth = 2;
+    let borderColor = "rgba(179, 157, 219, 1)";
+    if (data.length === 0) {
+      data.push({ name: "No views.", value: 1 });
+      max = { value: 1000 };
+      tooltipEnabled = false;
+      hoverBackgroundColor = "rgba(179, 157, 219, 0.1)";
+      hoverBorderWidth = 1;
+      borderColor = "rgba(179, 157, 219, 0.2)";
+    }
+
+    new Chart(ctx, {
+      type: "doughnut",
+      data: {
+        labels: data.map(d => d.name.replace(/\[dot\]/g, ".")),
+        datasets: [{
+          label: "Views",
+          data: data.map(d => d.value),
+          backgroundColor: data.map(d => `rgba(179, 157, 219, ${Math.max((d.value / max.value) - 0.2, 0.1).toFixed(2)})`),
+          borderWidth: 1,
+          borderColor,
+          hoverBackgroundColor,
+          hoverBorderWidth,
+        }]
+      },
+      options: {
+        plugins: {
+          legend: {
+            position: "left",
+            labels: {
+              boxWidth: 25,
+              font: { size: 11 }
+            }
+          },
+          tooltip: {
+            enabled: tooltipEnabled,
+            backgroundColor: "rgba(255, 255, 255, 0.95)",
+            titleColor: "#333",
+            titleFont: { weight: "normal", size: 15 },
+            bodyFont: { weight: "normal", size: 16 },
+            bodyColor: "rgb(179, 157, 219)",
+            padding: 12,
+            cornerRadius: 2,
+            borderColor: "rgba(0, 0, 0, 0.1)",
+            borderWidth: 1,
+            displayColors: false,
+          }
+        },
+        responsive: false,
+      }
+    });
+
+    // reset the display: block style that chart.js applies automatically
+    ctx.style.display = "";
+  });
+}
+
+
+// beautify browser lables
+function beautifyOsName(name) {
+  if (name === "android") return "Android";
+  if (name === "ios") return "iOS";
+  if (name === "linux") return "Linux";
+  if (name === "macos") return "macOS";
+  if (name === "windows") return "Windows";
+  if (name === "other") return "Other";
+  return name;
+}
+
+
+// create operation systems chart
+function createOsChart() {
+  const canvases = document.querySelectorAll("canvas.os");
+  if (!canvases || !canvases.length) return;
+
+  canvases.forEach(ctx => {
+    const data = JSON.parse(ctx.dataset.data);
+    const period = ctx.dataset.period;
+
+    const gradient = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
+    const gradientHover = ctx.getContext("2d").createLinearGradient(500, 0, 0, 0);
+    gradient.addColorStop(0, "rgba(179, 157, 219, 0.95)");   
+    gradient.addColorStop(1, "rgba(179, 157, 219, 0.05)");
+    gradientHover.addColorStop(0, "rgba(179, 157, 219, 0.9)");   
+    gradientHover.addColorStop(1, "rgba(179, 157, 219, 0.4)");
+
+    new Chart(ctx, {
+      type: "bar",
+      data: {
+        labels: data.map(d => beautifyOsName(d.name)),
+        datasets: [{
+          label: "Views",
+          data: data.map(d => d.value),
+          backgroundColor: gradient,
+          borderColor: "rgba(179, 157, 219, 1)",
+          borderWidth: 1,
+          hoverBackgroundColor: gradientHover,
+          hoverBorderWidth: 2
+        }]
+      },
+      options: {
+        indexAxis: "y",
+        plugins: {
+          legend: {
+            display: false,
+          },
+          tooltip: {
+            backgroundColor: "rgba(255, 255, 255, 0.95)",
+            titleColor: "#333",
+            titleFont: { weight: "normal", size: 15 },
+            bodyFont: { weight: "normal", size: 16 },
+            bodyColor: "rgb(179, 157, 219)",
+            padding: 12,
+            cornerRadius: 2,
+            borderColor: "rgba(0, 0, 0, 0.1)",
+            borderWidth: 1,
+            displayColors: false,
+          }
+        },
+        responsive: true,
+        interaction: {
+          intersect: false,
+          mode: "index",
+          axis: "y"
+        },
+        scales: {
+          x: {
+            grace:"5%",
+            beginAtZero: true,
+            ticks: {
+              maxTicksLimit: 6,
+            }
+          }
+        }  
+      }
+    });
+
+    // reset the display: block style that chart.js applies automatically
+    ctx.style.display = "";
+  });
+}
+
+// add data to the map
+function feedMapData(period) {
+  const map = document.querySelector("svg.map");
+  const paths = map.querySelectorAll("path");
+  if (!map || !paths || !paths.length) return;
+
+  let data = JSON.parse(map.dataset[period || "day"]);
+  if (!data) return;
+
+  let max = data.sort((a, b) => a.value > b.value ? -1 : 1)[0];
+
+  if (!max) max = { value: 1 }
+  
+  data = data.reduce((a, c) => ({ ...a, [c.name]: c.value }), {});
+  
+  for (let i = 0; i < paths.length; ++i) {
+    const id = paths[i].dataset.id;
+    const views = data[id] || 0;
+    paths[i].dataset.views = views;
+    const colorLevel = Math.ceil((views / max.value) * 6);  
+    const classList = paths[i].classList;
+    for (let j = 1; j < 7; j++) {
+      paths[i].classList.remove(`color-${j}`);
+    }
+    paths[i].classList.add(`color-${colorLevel}`)
+    paths[i].dataset.views = views;
+  }
+}
+
+// handle map tooltip hover
+function mapTooltipHoverOver() {
+  const tooltip = document.querySelector("#map-tooltip");
+  if (!tooltip) return;
+  if (!event.target.dataset.id) return mapTooltipHoverOut();
+  if (!tooltip.classList.contains("active")) {
+    tooltip.classList.add("visible");
+  }
+  tooltip.dataset.tooltip = `${event.target.ariaLabel}: ${event.target.dataset.views || 0}`;
+  const rect = event.target.getBoundingClientRect();
+  tooltip.style.top = rect.top + (rect.height / 2) + "px";
+  tooltip.style.left = rect.left + (rect.width / 2) + "px";
+  event.target.classList.add("active");
+}
+function mapTooltipHoverOut() {
+  const tooltip = document.querySelector("#map-tooltip");
+  const map = document.querySelector("svg.map");
+  const paths = map.querySelectorAll("path");
+  if (!tooltip || !map) return;
+  tooltip.classList.remove("visible");
+  for (let i = 0; i < paths.length; ++i) {
+    paths[i].classList.remove("active");
+  }
+}
+
+// create stats charts
+function createCharts() {
+  createViewsChart();
+  createBrowsersChart();
+  createReferrersChart();
+  createOsChart();
+  feedMapData();
+}
+
+// change stats period for showing charts and data
+function changeStatsPeriod(event) {
+  const period = event.target.dataset.period;
+  if (!period) return;
+  const canvases = document.querySelector("#stats").querySelectorAll("[data-period]");
+  const buttons = document.querySelector("#stats").querySelectorAll(".nav");
+  if (!buttons || !canvases) return;
+  buttons.forEach(b => b.disabled = false);
+  event.target.disabled = true;
+  canvases.forEach(canvas => {
+    if (canvas.dataset.period === period) {
+      canvas.classList.remove("hidden");
+    } else {
+      canvas.classList.add("hidden");
+    }
+  });
+  feedMapData(period);
 }

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.