Преглед на файлове

more rewrite: tables and settings page

Pouria Ezzati преди 1 година
родител
ревизия
5b647f05be
променени са 54 файла, в които са добавени 1429 реда и са изтрити 362 реда
  1. 46 9
      server/handlers/auth.handler.js
  2. 63 0
      server/handlers/domains.handler.js
  3. 0 34
      server/handlers/domains.handler.ts
  4. 27 6
      server/handlers/helpers.handler.js
  5. 58 47
      server/handlers/links.handler.js
  6. 22 4
      server/handlers/users.handler.js
  7. 105 76
      server/handlers/validators.handler.js
  8. 15 14
      server/queries/host.queries.js
  9. 2 2
      server/queries/index.js
  10. 67 13
      server/renders/renders.handler.js
  11. 65 6
      server/renders/renders.js
  12. 24 21
      server/routes/auth.routes.js
  13. 31 0
      server/routes/domain.routes.js
  14. 0 29
      server/routes/domain.routes.ts
  15. 13 11
      server/routes/link.routes.js
  16. 5 5
      server/routes/routes.js
  17. 28 0
      server/routes/user.routes.js
  18. 0 27
      server/routes/user.routes.ts
  19. 1 0
      server/server.js
  20. 1 0
      server/views/layout.hbs
  21. 1 0
      server/views/partials/icons/check.hbs
  22. 1 0
      server/views/partials/icons/copy.hbs
  23. 1 0
      server/views/partials/icons/key.hbs
  24. 1 0
      server/views/partials/icons/plus.hbs
  25. 2 0
      server/views/partials/icons/qrcode.hbs
  26. 2 0
      server/views/partials/icons/stop.hbs
  27. 1 0
      server/views/partials/icons/zap.hbs
  28. 32 0
      server/views/partials/links/actions.hbs
  29. 0 8
      server/views/partials/links/dialog.hbs
  30. 49 0
      server/views/partials/links/dialog/ban.hbs
  31. 12 0
      server/views/partials/links/dialog/ban_success.hbs
  32. 1 1
      server/views/partials/links/dialog/delete.hbs
  33. 0 0
      server/views/partials/links/dialog/delete_success.hbs
  34. 8 0
      server/views/partials/links/dialog/frame.hbs
  35. 5 1
      server/views/partials/links/dialog/message.hbs
  36. 0 0
      server/views/partials/links/dialog_content/main.hbs
  37. 2 2
      server/views/partials/links/edit.hbs
  38. 1 1
      server/views/partials/links/table.hbs
  39. 8 8
      server/views/partials/links/tr.hbs
  40. 46 0
      server/views/partials/settings/apikey.hbs
  41. 50 0
      server/views/partials/settings/change_email.hbs
  42. 50 0
      server/views/partials/settings/change_password.hbs
  43. 42 0
      server/views/partials/settings/delete_account.hbs
  44. 61 0
      server/views/partials/settings/domain/add_form.hbs
  45. 28 0
      server/views/partials/settings/domain/delete.hbs
  46. 12 0
      server/views/partials/settings/domain/delete_success.hbs
  47. 8 0
      server/views/partials/settings/domain/dialog.hbs
  48. 30 0
      server/views/partials/settings/domain/index.hbs
  49. 45 0
      server/views/partials/settings/domain/table.hbs
  50. 2 2
      server/views/partials/shortener.hbs
  51. 17 0
      server/views/settings.hbs
  52. 289 27
      static/css/styles.css
  53. 0 0
      static/libs/qrcode.min.js
  54. 49 8
      static/scripts/main.js

+ 46 - 9
server/handlers/auth.handler.js

@@ -37,6 +37,7 @@ function authenticate(type, error, isStrict) {
       }
 
       if (user) {
+        res.locals.isAdmin = utils.isAdmin(user.email);
         req.user = {
           ...user,
           admin: utils.isAdmin(user.email)
@@ -157,14 +158,31 @@ async function verify(req, res, next) {
  * @type {import("express").Handler}
  */
 async function changePassword(req, res) {
+  const isMatch = await bcrypt.compare(req.body.currentpassword, req.user.password);
+  if (!isMatch) {
+    const message = "Current password is not correct.";
+    res.locals.errors = { currentpassword: message };
+    throw new CustomError(message, 401);
+  }
+
   const salt = await bcrypt.genSalt(12);
-  const password = await bcrypt.hash(req.body.password, salt);
+  const newpassword = await bcrypt.hash(req.body.newpassword, salt);
   
-  const [user] = await query.user.update({ id: req.user.id }, { password });
+  const [user] = await query.user.update({ id: req.user.id }, { password: newpassword });
   
   if (!user) {
     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", {
+      success: "Password has been changed."
+    });
+    return;
+  }
   
   return res
     .status(200)
@@ -184,6 +202,15 @@ async function generateApiKey(req, res) {
   if (!user) {
     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 },
+    });
+    return;
+  }
   
   return res.status(201).send({ apikey });
 }
@@ -249,13 +276,17 @@ async function changeEmailRequest(req, res) {
   const isMatch = await bcrypt.compare(password, req.user.password);
   
   if (!isMatch) {
-    throw new CustomError("Password is wrong.", 400);
+    const error = "Password is not correct.";
+    res.locals.errors = { password: error };
+    throw new CustomError(error, 401);
   }
   
   const currentUser = await query.user.find({ email });
   
   if (currentUser) {
-    throw new CustomError("Can't use this email address.", 400);
+    const error = "Can't use this email address.";
+    res.locals.errors = { email: error };
+    throw new CustomError(error, 400);
   }
   
   const [updatedUser] = await query.user.update(
@@ -272,12 +303,18 @@ async function changeEmailRequest(req, res) {
   if (updatedUser) {
     await mail.changeEmail({ ...updatedUser, email });
   }
+
+  const message = "A verification link has been sent to the requested email address."
   
-  return res.status(200).send({
-    message:
-      "If email address exists, an email " +
-      "with a verification link has been sent."
-  });
+  if (req.isHTML) {
+    res.setHeader("HX-Trigger-After-Swap", "resetChangeEmailForm");
+    res.render("partials/settings/change_email", {
+      success: message
+    });
+    return;
+  }
+  
+  return res.status(200).send({ message });
 }
 
 /**

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

@@ -0,0 +1,63 @@
+const { Handler } = require("express");
+
+const { CustomError, sanitize, sleep } = require("../utils");
+const query = require("../queries");
+const redis = require("../redis");
+
+async function add(req, res) {
+  const { address, homepage } = req.body;
+
+  const domain = await query.domain.add({
+    address,
+    homepage,
+    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");
+    res.render("partials/settings/domain/table", {
+      domains
+    });
+    return;
+  }
+  
+  return res.status(200).send(sanitize.domain(domain));
+};
+
+async function remove(req, res) {
+  const [domain] = await query.domain.update(
+    {
+      uuid: req.params.id,
+      user_id: req.user.id
+    },
+    { user_id: null }
+  );
+
+  redis.remove.domain(domain);
+
+  await sleep(1000);
+
+  if (!domain) {
+    throw new CustomError("Could not delete the domain.", 500);
+  }
+
+  if (req.isHTML) {
+    const domains = (await query.domain.get({ user_id: req.user.id })).map(sanitize.domain);
+    res.setHeader("HX-Reswap", "outerHTML");
+    res.render("partials/settings/domain/delete_success", {
+      domains,
+      address: domain.address,
+    });
+    return;
+  }
+
+  return res.status(200).send({ message: "Domain deleted successfully" });
+};
+
+module.exports = {
+  add,
+  remove,
+}

+ 0 - 34
server/handlers/domains.handler.ts

@@ -1,34 +0,0 @@
-import { Handler } from "express";
-import query from "../queries";
-import * as redis from "../redis";
-import { CustomError, sanitize } from "../utils/utils";
-
-export const add: Handler = async (req, res) => {
-  const { address, homepage } = req.body;
-
-  const domain = await query.domain.add({
-    address,
-    homepage,
-    user_id: req.user.id
-  });
-
-  return res.status(200).send(sanitize.domain(domain));
-};
-
-export const remove: Handler = async (req, res) => {
-  const [domain] = await query.domain.update(
-    {
-      uuid: req.params.id,
-      user_id: req.user.id
-    },
-    { user_id: null }
-  );
-
-  redis.remove.domain(domain);
-
-  if (!domain) {
-    throw new CustomError("Could not delete the domain.", 500);
-  }
-
-  return res.status(200).send({ message: "Domain deleted successfully" });
-};

+ 27 - 6
server/handlers/helpers.handler.js

@@ -2,7 +2,8 @@ const { validationResult } = require("express-validator");
 const signale = require("signale");
 
 const { logger } = require("../config/winston");
-const { CustomError } = require("../utils");
+const { CustomError, sanitize } = require("../utils");
+const query = require("../queries")
 const env = require("../env");
 
 // export const ip: Handler = (req, res, next) => {
@@ -20,10 +21,10 @@ function isHTML(req, res, next) {
   next();
 }
 
+function addNoLayoutLocals(req, res, next) {
 /**
  * @type {import("express").Handler}
  */
-function noRenderLayout(req, res, next) {
   res.locals.layout = null;
   next();
 }
@@ -35,6 +36,24 @@ function viewTemplate(template) {
   }
 }
 
+/**
+ * @type {import("express").Handler}
+ */
+function addConfigLocals(req, res, next) {
+  res.locals.default_domain = env.DEFAULT_DOMAIN;
+  next();
+}
+
+/**
+ * @type {import("express").Handler}
+ */
+async function addUserLocals(req, res, next) {
+  const user = req.user;
+  res.locals.user = user;
+  res.locals.domains = user && (await query.domain.get({ user_id: user.id })).map(sanitize.domain);
+  next();
+}
+
 /**
  * @type {import("express").ErrorRequestHandler}
  */
@@ -51,7 +70,7 @@ function error(error, req, res, _next) {
     return;
   }
 
-  return res.status(500).json({ error: message });
+  return res.status(statusCode).json({ error: message });
 };
 
 
@@ -75,7 +94,7 @@ function verify(req, res, next) {
   throw new CustomError(error, 400);
 }
 
-function query(req, res, next) {
+function parseQuery(req, res, next) {
   const { admin } = req.user || {};
 
   if (
@@ -112,10 +131,12 @@ function query(req, res, next) {
 };
 
 module.exports = {
+  addConfigLocals,
+  addNoLayoutLocals,
+  addUserLocals,
   error,
   isHTML,
-  noRenderLayout,
-  query,
+  parseQuery,
   verify,
   viewTemplate,
 }

+ 58 - 47
server/handlers/links.handler.js

@@ -116,7 +116,6 @@ async function create(req, res) {
   
   if (req.isHTML) {
     res.setHeader("HX-Trigger", "reloadLinks");
-    res.setHeader("HX-Trigger-After-Swap", "resetForm");
     const shortURL = utils.getShortURL(link.address, link.domain);
     return res.render("partials/shortener", {
       link: shortURL.link, 
@@ -235,7 +234,7 @@ async function remove(req, res) {
   if (req.isHTML) {
     res.setHeader("HX-Reswap", "outerHTML");
     res.setHeader("HX-Trigger", "reloadLinks");
-    res.render("partials/links/dialog_delete_success", {
+    res.render("partials/links/dialog/delete_success", {
       link: utils.getShortURL(link.address, link.domain).link,
     });
     return;
@@ -265,64 +264,75 @@ async function remove(req, res) {
 //     .send({ message: "Thanks for the report, we'll take actions shortly." });
 // };
 
-// export const ban: Handler = async (req, res) => {
-//   const { id } = req.params;
+async function ban(req, res) {
+  const { id } = req.params;
 
-//   const update = {
-//     banned_by_id: req.user.id,
-//     banned: true
-//   };
+  const update = {
+    banned_by_id: req.user.id,
+    banned: true
+  };
 
-//   // 1. Check if link exists
-//   const link = await query.link.find({ uuid: id });
+  // 1. Check if link exists
+  const link = await query.link.find({ uuid: id });
 
-//   if (!link) {
-//     throw new CustomError("No link has been found.", 400);
-//   }
+  if (!link) {
+    throw new CustomError("No link has been found.", 400);
+  }
 
-//   if (link.banned) {
-//     return res.status(200).send({ message: "Link has been banned already." });
-//   }
+  if (link.banned) {
+    throw new CustomError("Link has been banned already.", 400);
+  }
 
-//   const tasks = [];
+  const tasks = [];
 
-//   // 2. Ban link
-//   tasks.push(query.link.update({ uuid: id }, update));
+  // 2. Ban link
+  tasks.push(query.link.update({ uuid: id }, update));
 
-//   const domain = utils.removeWww(URL.parse(link.target).hostname);
+  const domain = utils.removeWww(URL.parse(link.target).hostname);
 
-//   // 3. Ban target's domain
-//   if (req.body.domain) {
-//     tasks.push(query.domain.add({ ...update, address: domain }));
-//   }
+  // 3. Ban target's domain
+  if (req.body.domain) {
+    tasks.push(query.domain.add({ ...update, address: domain }));
+  }
 
-//   // 4. Ban target's host
-//   if (req.body.host) {
-//     const dnsRes = await dnsLookup(domain).catch(() => {
-//       throw new CustomError("Couldn't fetch DNS info.");
-//     });
-//     const host = dnsRes?.address;
-//     tasks.push(query.host.add({ ...update, address: host }));
-//   }
+  // 4. Ban target's host
+  if (req.body.host) {
+    const dnsRes = await dnsLookup(domain).catch(() => {
+      throw new CustomError("Couldn't fetch DNS info.");
+    });
+    const host = dnsRes?.address;
+    tasks.push(query.host.add({ ...update, address: host }));
+  }
 
-//   // 5. Ban link owner
-//   if (req.body.user && link.user_id) {
-//     tasks.push(query.user.update({ id: link.user_id }, update));
-//   }
+  // 5. Ban link owner
+  if (req.body.user && link.user_id) {
+    tasks.push(query.user.update({ id: link.user_id }, update));
+  }
 
-//   // 6. Ban all of owner's links
-//   if (req.body.userLinks && link.user_id) {
-//     tasks.push(query.link.update({ user_id: link.user_id }, update));
-//   }
+  // 6. Ban all of owner's links
+  if (req.body.userLinks && link.user_id) {
+    tasks.push(query.link.update({ user_id: link.user_id }, update));
+  }
 
-//   // 7. Wait for all tasks to finish
-//   await Promise.all(tasks).catch(() => {
-//     throw new CustomError("Couldn't ban entries.");
-//   });
+  // 7. Wait for all tasks to finish
+  await Promise.all(tasks).catch((err) => {
+    throw new CustomError("Couldn't ban entries.");
+  });
 
-//   // 8. Send response
-//   return res.status(200).send({ message: "Banned link successfully." });
-// };
+  // 8. Send response
+  await utils.sleep(1000);
+  
+  if (req.isHTML) {
+    res.setHeader("HX-Reswap", "outerHTML");
+    res.setHeader("HX-Trigger", "reloadLinks");
+    res.render("partials/links/dialog/ban_success", {
+      link: utils.getShortURL(link.address, link.domain).link,
+    });
+    return;
+  }
+
+  return res.status(200).send({ message: "Banned link successfully." });
+};
 
 // export const redirect = (app) => async (
 //   req,
@@ -468,6 +478,7 @@ async function remove(req, res) {
 // };
 
 module.exports = {
+  ban,
   create,
   edit,
   get,

+ 22 - 4
server/handlers/users.handler.js

@@ -1,7 +1,8 @@
-import query from "../queries";
-import * as utils from "../utils/utils";
+const query = require("../queries");
+const utils = require("../utils");
+const env = require("../env");
 
-export const get = async (req, res) => {
+async function get(req, res) {
   const domains = await query.domain.get({ user_id: req.user.id });
 
   const data = {
@@ -13,7 +14,24 @@ export const get = async (req, res) => {
   return res.status(200).send(data);
 };
 
-export const remove = async (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");
+    res.render("partials/settings/delete_account", {
+      success: "Account has been deleted. Logging out..."
+    });
+    return;
+  }
+  
   return res.status(200).send("OK");
 };
+
+module.exports = {
+  get,
+  remove,
+}

+ 105 - 76
server/handlers/validators.handler.js

@@ -16,6 +16,7 @@ const env = require("../env");
 const dnsLookup = promisify(dns.lookup);
 
 const checkUser = (value, { req }) => !!req.user;
+const sanitizeCheckbox = value => value === true || value === "on" || value;
 
 let body1;
 let body2;
@@ -177,39 +178,39 @@ const editLink = [
 //     .isLength({ min: 36, max: 36 })
 // ];
 
-// export const addDomain = [
-//   body("address", "Domain is not valid")
-//     .exists({ checkFalsy: true, checkNull: true })
-//     .isLength({ min: 3, max: 64 })
-//     .withMessage("Domain length must be between 3 and 64.")
-//     .trim()
-//     .customSanitizer(value => {
-//       const parsed = URL.parse(value);
-//       return removeWww(parsed.hostname || parsed.href);
-//     })
-//     .custom(value => urlRegex({ exact: true, strict: false }).test(value))
-//     .custom(value => value !== env.DEFAULT_DOMAIN)
-//     .withMessage("You can't use the default domain.")
-//     .custom(async value => {
-//       const domain = await query.domain.find({ address: value });
-//       if (domain?.user_id || domain?.banned) return Promise.reject();
-//     })
-//     .withMessage("You can't add this domain."),
-//   body("homepage")
-//     .optional({ checkFalsy: true, nullable: true })
-//     .customSanitizer(addProtocol)
-//     .custom(value => urlRegex({ exact: true, strict: false }).test(value))
-//     .withMessage("Homepage is not valid.")
-// ];
+const addDomain = [
+  body("address", "Domain is not valid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .isLength({ min: 3, max: 64 })
+    .withMessage("Domain length must be between 3 and 64.")
+    .trim()
+    .customSanitizer(value => {
+      const parsed = URL.parse(value);
+      return removeWww(parsed.hostname || parsed.href);
+    })
+    .custom(value => urlRegex({ exact: true, strict: false }).test(value))
+    .custom(value => value !== env.DEFAULT_DOMAIN)
+    .withMessage("You can't use the default domain.")
+    .custom(async value => {
+      const domain = await query.domain.find({ address: value });
+      if (domain?.user_id || domain?.banned) return Promise.reject();
+    })
+    .withMessage("You can't add this domain."),
+  body("homepage")
+    .optional({ checkFalsy: true, nullable: true })
+    .customSanitizer(addProtocol)
+    .custom(value => urlRegex({ exact: true, strict: false }).test(value))
+    .withMessage("Homepage is not valid.")
+];
 
-// export const removeDomain = [
-//   param("id", "ID is invalid.")
-//     .exists({
-//       checkFalsy: true,
-//       checkNull: true
-//     })
-//     .isLength({ min: 36, max: 36 })
-// ];
+const removeDomain = [
+  param("id", "ID is invalid.")
+    .exists({
+      checkFalsy: true,
+      checkNull: true
+    })
+    .isLength({ min: 36, max: 36 })
+];
 
 const deleteLink = [
   param("id", "ID is invalid.")
@@ -233,34 +234,38 @@ const deleteLink = [
 //     .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
 // ];
 
-// export const banLink = [
-//   param("id", "ID is invalid.")
-//     .exists({
-//       checkFalsy: true,
-//       checkNull: true
-//     })
-//     .isLength({ min: 36, max: 36 }),
-//   body("host", '"host" should be a boolean.')
-//     .optional({
-//       nullable: true
-//     })
-//     .isBoolean(),
-//   body("user", '"user" should be a boolean.')
-//     .optional({
-//       nullable: true
-//     })
-//     .isBoolean(),
-//   body("userlinks", '"userlinks" should be a boolean.')
-//     .optional({
-//       nullable: true
-//     })
-//     .isBoolean(),
-//   body("domain", '"domain" should be a boolean.')
-//     .optional({
-//       nullable: true
-//     })
-//     .isBoolean()
-// ];
+const banLink = [
+  param("id", "ID is invalid.")
+    .exists({
+      checkFalsy: true,
+      checkNull: true
+    })
+    .isLength({ min: 36, max: 36 }),
+  body("host", '"host" should be a boolean.')
+    .optional({
+      nullable: true
+    })
+    .customSanitizer(sanitizeCheckbox)
+    .isBoolean(),
+  body("user", '"user" should be a boolean.')
+    .optional({
+      nullable: true
+    })
+    .customSanitizer(sanitizeCheckbox)
+    .isBoolean(),
+  body("userLinks", '"userLinks" should be a boolean.')
+    .optional({
+      nullable: true
+    })
+    .customSanitizer(sanitizeCheckbox)
+    .isBoolean(),
+  body("domain", '"domain" should be a boolean.')
+    .optional({
+      nullable: true
+    })
+    .customSanitizer(sanitizeCheckbox)
+    .isBoolean()
+];
 
 // export const getStats = [
 //   param("id", "ID is invalid.")
@@ -303,16 +308,33 @@ const login = [
     .exists({ checkFalsy: true, checkNull: true })
     .trim()
     .isEmail()
-    .isLength({ min: 0, max: 255 })
+    .isLength({ min: 1, max: 255 })
     .withMessage("Email length must be max 255.")
 ];
 
-// export const changePassword = [
-//   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 changePassword = [
+  body("currentpassword", "Password is not valid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .isLength({ min: 8, max: 64 })
+    .withMessage("Password length must be between 8 and 64."),
+  body("newpassword", "Password is not valid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .isLength({ min: 8, max: 64 })
+    .withMessage("Password length must be between 8 and 64.")
+];
+
+const changeEmail = [
+  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."),
+  body("email", "Email address is not valid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .trim()
+    .isEmail()
+    .isLength({ min: 1, max: 255 })
+    .withMessage("Email length must be max 255.")
+];
 
 // export const resetPasswordRequest = [
 //   body("email", "Email is not valid.")
@@ -336,15 +358,16 @@ const login = [
 //     .withMessage("Email length must be max 255.")
 // ];
 
-// export const deleteUser = [
-//   body("password", "Password is not valid.")
-//     .exists({ checkFalsy: true, checkNull: true })
-//     .isLength({ min: 8, max: 64 })
-//     .custom(async (password, { req }) => {
-//       const isMatch = await bcrypt.compare(password, req.user.password);
-//       if (!isMatch) return Promise.reject();
-//     })
-// ];
+const deleteUser = [
+  body("password", "Password is not valid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .isLength({ min: 8, max: 64 })
+    .custom(async (password, { req }) => {
+      const isMatch = await bcrypt.compare(password, req.user.password);
+      if (!isMatch) return Promise.reject();
+    })
+    .withMessage("Password is not correct.")
+];
 
 // TODO: if user has posted malware should do something better
 function cooldown(user) {
@@ -461,15 +484,21 @@ async function bannedHost(domain) {
 };
 
 module.exports = {
+  addDomain,
+  banLink,
   bannedDomain,
   bannedHost,
+  changeEmail,
+  changePassword,
   checkUser,
   cooldown,
   createLink,
   deleteLink,
+  deleteUser,
   editLink,
   linksCount,
   login, 
   malware,
+  removeDomain,
   signup,
 }

+ 15 - 14
server/queries/host.queries.ts → server/queries/host.queries.js

@@ -1,17 +1,13 @@
-import redisClient, * as redis from "../redis";
-import knex from "../knex";
+const redis = require("../redis");
+const knex = require("../knex");
 
-interface Add extends Partial<Host> {
-  address: string;
-}
-
-export const find = async (match: Partial<Host>): Promise<Host> => {
+async function find(match) {
   if (match.address) {
-    const cachedHost = await redisClient.get(redis.key.host(match.address));
+    const cachedHost = await redis.client.get(redis.key.host(match.address));
     if (cachedHost) return JSON.parse(cachedHost);
   }
 
-  const host = await knex<Domain>("hosts")
+  const host = await knex("hosts")
     .where(match)
     .first();
 
@@ -27,10 +23,10 @@ export const find = async (match: Partial<Host>): Promise<Host> => {
   return host;
 };
 
-export const add = async (params: Add) => {
+async function add(params) {
   params.address = params.address.toLowerCase();
 
-  const exists = await knex<Domain>("domains")
+  const exists = await knex("hosts")
     .where("address", params.address)
     .first();
 
@@ -39,9 +35,9 @@ export const add = async (params: Add) => {
     banned: !!params.banned
   };
 
-  let host: Host;
+  let host;
   if (exists) {
-    const [response] = await knex<Host>("hosts")
+    const [response] = await knex("hosts")
       .where("id", exists.id)
       .update(
         {
@@ -52,7 +48,7 @@ export const add = async (params: Add) => {
       );
     host = response;
   } else {
-    const [response] = await knex<Host>("hosts").insert(newHost, "*");
+    const [response] = await knex("hosts").insert(newHost, "*");
     host = response;
   }
 
@@ -60,3 +56,8 @@ export const add = async (params: Add) => {
 
   return host;
 };
+
+module.exports = {
+  add,
+  find,
+}

+ 2 - 2
server/queries/index.js

@@ -2,12 +2,12 @@
 const domain = require("./domain.queries");
 const link = require("./link.queries");
 const user = require("./user.queries");
-// const host = require("./host.queries");
+const host = require("./host.queries");
 const ip = require("./ip.queries");
 
 module.exports = {
   domain,
-  // host,
+  host,
   ip,
   link,
   user,

+ 67 - 13
server/renders/renders.handler.js

@@ -6,16 +6,8 @@ const env = require("../env");
  * @type {import("express").Handler}
  */
 async function homepage(req, res) {
-  const user = req.user;
-
-  const default_domain = env.DEFAULT_DOMAIN;
-  const domains = user && await query.domain.get({ user_id: user.id });
-
   res.render("homepage", {
     title: "Modern open source URL shortener",
-    user,
-    domains,
-    default_domain,
   });
 }
 
@@ -41,6 +33,20 @@ function logout(req, res) {
   });
 }
 
+/**
+ * @type {import("express").Handler}
+ */
+function settings(req, res) {
+  // TODO: make this a middelware function, apply it to where it's necessary
+  if (!req.user) {
+    return res.redirect("/");
+  }
+  res.render("settings", {
+    title: "Settings"
+  });
+}
+
+
 /**
  * @type {import("express").Handler}
  */
@@ -51,18 +57,64 @@ async function confirmLinkDelete(req, res) {
   });
   await utils.sleep(500);
   if (!link) {
-    return res.render("partials/links/dialog_message", {
+    return res.render("partials/links/dialog/message", {
       layout: false,
       message: "Could not find the link."
     });
   }
-  res.render("partials/links/dialog_delete", {
+  res.render("partials/links/dialog/delete", {
     layout: false,
     link: utils.getShortURL(link.address, link.domain).link,
     id: link.uuid
   });
 }
 
+/**
+ * @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."
+    });
+  }
+  res.render("partials/links/dialog/ban", {
+    link: utils.getShortURL(link.address, link.domain).link,
+    id: link.uuid
+  });
+}
+
+/**
+ * @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);
+  }
+  res.render("partials/settings/domain/delete", {
+    ...utils.sanitize.domain(domain)
+  });
+}
+
+
 /**
  * @type {import("express").Handler}
  */
@@ -71,25 +123,27 @@ async function linkEdit(req, res) {
     uuid: req.params.id,
     ...(!req.user.admin && { user_id: req.user.id })
   });
-  console.log(utils.sanitize.link(link));
   await utils.sleep(500);
   // TODO: handle when no link
   // if (!link) {
-  //   return res.render("partials/links/dialog_message", {
+  //   return res.render("partials/links/dialog/message", {
   //     layout: false,
   //     message: "Could not find the link."
   //   });
   // }
   res.render("partials/links/edit", {
-    layout: false,
     ...utils.sanitize.link(link),
   });
 }
 
 module.exports = {
+  addDomainForm,
   homepage,
   linkEdit,
   login,
   logout,
+  confirmDomainDelete,
+  confirmLinkBan,
   confirmLinkDelete,
+  settings,
 }

+ 65 - 6
server/renders/renders.js

@@ -1,17 +1,76 @@
 const asyncHandler = require("express-async-handler");
 const { Router } = require("express");
 
+const helpers = require("../handlers/helpers.handler");
 const auth = require("../handlers/auth.handler");
 const renders = require("./renders.handler");
 
 const router = Router();
 
-router.use(asyncHandler(auth.jwtLoose));
+// pages
+router.get(
+  "/",
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(helpers.addUserLocals), 
+  asyncHandler(renders.homepage)
+);
 
-router.get("/", renders.homepage);
-router.get("/login", renders.login);
-router.get("/logout", renders.logout);
-router.get("/confirm-link-delete", renders.confirmLinkDelete);
-router.get("/link/edit/:id", renders.linkEdit);
+router.get(
+  "/login", 
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(renders.login)
+);
+
+router.get(
+  "/logout", 
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(renders.logout)
+);
+
+router.get(
+  "/settings",
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(helpers.addUserLocals),
+  asyncHandler(renders.settings)
+);
+
+// partial renders
+router.get(
+  "/confirm-link-delete", 
+  helpers.addNoLayoutLocals,
+  asyncHandler(auth.jwt),
+  asyncHandler(renders.confirmLinkDelete)
+);
+
+router.get(
+  "/confirm-link-ban", 
+  helpers.addNoLayoutLocals,
+  helpers.viewTemplate("partials/links/dialog/message"),
+  asyncHandler(auth.jwt),
+  asyncHandler(auth.admin), 
+  asyncHandler(renders.confirmLinkBan)
+);
+
+router.get(
+  "/link/edit/:id",
+  helpers.addNoLayoutLocals,
+  asyncHandler(auth.jwt),
+  asyncHandler(renders.linkEdit)
+);
+
+router.get(
+  "/add-domain-form", 
+  helpers.addNoLayoutLocals,
+  asyncHandler(auth.jwt),
+  asyncHandler(renders.addDomainForm)
+);
+
+router.get(
+  "/confirm-domain-delete", 
+  helpers.addNoLayoutLocals,
+  helpers.viewTemplate("partials/settings/domain/delete"),
+  asyncHandler(auth.jwt),
+  asyncHandler(renders.confirmDomainDelete)
+);
 
 module.exports = router;

+ 24 - 21
server/routes/auth.routes.js

@@ -27,27 +27,30 @@ router.post(
 
 // router.post("/renew", asyncHandler(auth.jwt), asyncHandler(auth.token));
 
-// router.post(
-//   "/change-password",
-//   asyncHandler(auth.jwt),
-//   validators.changePassword,
-//   asyncHandler(helpers.verify),
-//   asyncHandler(auth.changePassword)
-// );
-
-// router.post(
-//   "/change-email",
-//   asyncHandler(auth.jwt),
-//   validators.changePassword,
-//   asyncHandler(helpers.verify),
-//   asyncHandler(auth.changeEmailRequest)
-// );
-
-// router.post(
-//   "/apikey",
-//   asyncHandler(auth.jwt),
-//   asyncHandler(auth.generateApiKey)
-// );
+router.post(
+  "/change-password",
+  helpers.viewTemplate("partials/settings/change_password"),
+  asyncHandler(auth.jwt),
+  validators.changePassword,
+  asyncHandler(helpers.verify),
+  asyncHandler(auth.changePassword)
+);
+
+router.post(
+  "/change-email",
+  helpers.viewTemplate("partials/settings/change_email"),
+  asyncHandler(auth.jwt),
+  validators.changeEmail,
+  asyncHandler(helpers.verify),
+  asyncHandler(auth.changeEmailRequest)
+);
+
+router.post(
+  "/apikey",
+  helpers.viewTemplate("partials/settings/apikey"),
+  asyncHandler(auth.jwt),
+  asyncHandler(auth.generateApiKey)
+);
 
 // router.post("/reset-password", asyncHandler(auth.resetPasswordRequest));
 

+ 31 - 0
server/routes/domain.routes.js

@@ -0,0 +1,31 @@
+const { Router } = require("express");
+const asyncHandler = require("express-async-handler");
+
+const validators = require("../handlers/validators.handler");
+const helpers = require("../handlers/helpers.handler");
+const domains = require("../handlers/domains.handler");
+const auth = require("../handlers/auth.handler");
+
+const router = Router();
+
+router.post(
+  "/",
+  helpers.viewTemplate("partials/settings/domain/add_form"),
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  validators.addDomain,
+  asyncHandler(helpers.verify),
+  asyncHandler(domains.add)
+);
+
+router.delete(
+  "/:id",
+  helpers.viewTemplate("partials/settings/domain/delete"),
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  validators.removeDomain,
+  asyncHandler(helpers.verify),
+  asyncHandler(domains.remove)
+);
+
+module.exports = router;

+ 0 - 29
server/routes/domain.routes.ts

@@ -1,29 +0,0 @@
-import { Router } from "express";
-import asyncHandler from "express-async-handler";
-
-import * as validators from "../handlers/validators.handler";
-import * as helpers from "../handlers/helpers.handler";
-import * as domains from "../handlers/domains.handler";
-import * as auth from "../handlers/auth.handler";
-
-const router = Router();
-
-router.post(
-  "/",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  validators.addDomain,
-  asyncHandler(helpers.verify),
-  asyncHandler(domains.add)
-);
-
-router.delete(
-  "/:id",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  validators.removeDomain,
-  asyncHandler(helpers.verify),
-  asyncHandler(domains.remove)
-);
-
-export default router;

+ 13 - 11
server/routes/link.routes.js

@@ -17,7 +17,7 @@ router.get(
   helpers.viewTemplate("partials/links/table"),
   asyncHandler(auth.apikey),
   asyncHandler(auth.jwt),
-  helpers.query,
+  helpers.parseQuery,
   asyncHandler(link.get)
 );
 
@@ -47,7 +47,7 @@ router.patch(
 
 router.delete(
   "/:id",
-  helpers.viewTemplate("partials/links/dialog_delete"),
+  helpers.viewTemplate("partials/links/dialog/delete"),
   asyncHandler(auth.apikey),
   asyncHandler(auth.jwt),
   validators.deleteLink,
@@ -55,6 +55,17 @@ router.delete(
   asyncHandler(link.remove)
 );
 
+router.post(
+  "/admin/ban/:id",
+  helpers.viewTemplate("partials/links/dialog/ban"),
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  asyncHandler(auth.admin),
+  validators.banLink,
+  asyncHandler(helpers.verify),
+  asyncHandler(link.ban)
+);
+
 // router.get(
 //   "/:id/stats",
 //   asyncHandler(auth.apikey),
@@ -77,14 +88,5 @@ router.delete(
 //   asyncHandler(link.report)
 // );
 
-// router.post(
-//   "/admin/ban/:id",
-//   asyncHandler(auth.apikey),
-//   asyncHandler(auth.jwt),
-//   asyncHandler(auth.admin),
-//   validators.banLink,
-//   asyncHandler(helpers.verify),
-//   asyncHandler(link.ban)
-// );
 
 module.exports = router;

+ 5 - 5
server/routes/routes.js

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

+ 28 - 0
server/routes/user.routes.js

@@ -0,0 +1,28 @@
+const asyncHandler = require("express-async-handler");
+const { Router } = require("express");
+
+const validators = require("../handlers/validators.handler");
+const helpers = require("../handlers/helpers.handler");
+const user = require("../handlers/users.handler");
+const auth = require("../handlers/auth.handler");
+
+const router = Router();
+
+router.get(
+  "/",
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  asyncHandler(user.get)
+);
+
+router.post(
+  "/delete",
+  helpers.viewTemplate("partials/settings/delete_account"),
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  validators.deleteUser,
+  asyncHandler(helpers.verify),
+  asyncHandler(user.remove)
+);
+
+module.exports = router;

+ 0 - 27
server/routes/user.routes.ts

@@ -1,27 +0,0 @@
-import { Router } from "express";
-import asyncHandler from "express-async-handler";
-
-import * as validators from "../handlers/validators.handler";
-import * as helpers from "../handlers/helpers.handler";
-import * as user from "../handlers/users.handler";
-import * as auth from "../handlers/auth.handler";
-
-const router = Router();
-
-router.get(
-  "/",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  asyncHandler(user.get)
-);
-
-router.post(
-  "/delete",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  validators.deleteUser,
-  asyncHandler(helpers.verify),
-  asyncHandler(user.remove)
-);
-
-export default router;

+ 1 - 0
server/server.js

@@ -38,6 +38,7 @@ app.use(express.static("static"));
 // app.use(passport.initialize());
 // app.use(helpers.ip);
 app.use(helpers.isHTML);
+app.use(helpers.addConfigLocals);
 
 // template engine / serve html
 app.set("view engine", "hbs");

+ 1 - 0
server/views/layout.hbs

@@ -54,6 +54,7 @@
 
   {{{block "scripts"}}}
   <script src="/libs/htmx.min.js"></script>
+  <script src="/libs/qrcode.min.js"></script>
   <script src="/scripts/main.js"></script>
 </body>
 </html>

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

@@ -0,0 +1 @@
+<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>

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

@@ -0,0 +1 @@
+<svg class="copy" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect width="13" height="13" x="9" y="9" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="m21 2-2 2m-7.6 7.6a5.5 5.5 0 1 1-7.8 7.8 5.5 5.5 0 0 1 7.8-7.8zm0 0 4.1-4.1m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 5v14m-7-7h14"/></svg>

+ 2 - 0
server/views/partials/icons/qrcode.hbs

@@ -0,0 +1,2 @@
+
+<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" preserveAspectRatio="xMinYMin" viewBox="-2 -2 24 24"><path d="M13 18h3a2 2 0 0 0 2-2v-3a1 1 0 0 1 2 0v3a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4v-3a1 1 0 0 1 2 0v3a2 2 0 0 0 2 2h3a1 1 0 0 1 0 2h6a1 1 0 0 1 0-2M2 7a1 1 0 1 1-2 0V4a4 4 0 0 1 4-4h3a1 1 0 1 1 0 2H4a2 2 0 0 0-2 2zm16 0V4a2 2 0 0 0-2-2h-3a1 1 0 0 1 0-2h3a4 4 0 0 1 4 4v3a1 1 0 0 1-2 0"/></svg>

+ 2 - 0
server/views/partials/icons/stop.hbs

@@ -0,0 +1,2 @@
+
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#5c666b" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M4.93 4.93L19.07 19.07"></path></svg>

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

@@ -0,0 +1 @@
+<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>

+ 32 - 0
server/views/partials/links/actions.hbs

@@ -1,7 +1,24 @@
 <td class="actions">
+  {{#if password}}
+    <button class="action password" disabled="true" data-tooltip="Password protected">
+      {{> icons/key}}
+    </button>
+  {{/if}}
+  {{#if banned}}
+    <button class="action banned" disabled="true" data-tooltip="Banned">
+      {{> icons/stop}}
+    </button>
+  {{/if}}
   <button class="action stats">
     {{> icons/chart}}
   </button>
+  <button
+    class="action qrcode"
+    hx-on:click="handleQRCode(this)"
+    data-url="{{link.url}}"
+  >
+    {{> icons/qrcode}}
+  </button>
   <button 
     class="action edit"
     hx-trigger="click queue:none"
@@ -11,6 +28,7 @@
     hx-swap="beforeend"
     hx-target="next tr.edit"
     hx-indicator="next tr.edit"
+    hx-sync="this:drop"
     hx-on::before-request="
       const tr = event.detail.target;
       tr.classList.add('show');
@@ -23,6 +41,20 @@
   >
     {{> icons/pencil}}
   </button>
+  {{#unless banned}}
+    {{#if @root.isAdmin}}
+      <button 
+        class="action ban" 
+        hx-on:click='openDialog("link-dialog")' 
+        hx-get="/confirm-link-ban" 
+        hx-target="#link-dialog .content-wrapper" 
+        hx-indicator="#link-dialog" 
+        hx-vals='{"id":"{{id}}"}'
+      >
+        {{> icons/stop}}
+      </button>
+    {{/if}}
+  {{/unless}}
   <button 
     class="action delete" 
     hx-on:click='openDialog("link-dialog")' 

+ 0 - 8
server/views/partials/links/dialog.hbs

@@ -1,8 +0,0 @@
-<div id="link-dialog" class="dialog">
-    <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>
-      </div>
-    </div>
-  </div>

+ 49 - 0
server/views/partials/links/dialog/ban.hbs

@@ -0,0 +1,49 @@
+<div class="content">
+  <h2>Ban link?</h2>
+  <p>
+    Are you sure do you want to ban the link &quot;<b>{{link}}</b>&quot;?
+  </p>
+  <div class="ban-checklist">
+    <label class="checkbox">
+      <input id="user" name="user" type="checkbox" />
+      User
+    </label>
+    <label class="checkbox">
+      <input id="userLinks" name="userLinks" type="checkbox" />
+      User links
+    </label>
+    <label class="checkbox">
+      <input id="host" name="host" type="checkbox" />
+      Host
+    </label>
+    <label class="checkbox">
+      <input id="domain" name="domain" type="checkbox" />
+      Domain
+    </label>
+  </div>
+  <div class="buttons">
+    <button hx-on:click="closeDialog()">Cancel</button>
+    <button 
+      class="danger confirm" 
+      hx-post="/api/links/admin/ban/{id}" 
+      hx-ext="path-params" 
+      hx-vals='{"id":"{{id}}"}' 
+      hx-target="closest .content" 
+      hx-swap="none" 
+      hx-include=".ban-checklist"
+      hx-indicator="closest .content"
+      hx-select-oob="#dialog-error"
+    >
+      <span class="stop">
+        {{> icons/stop}}
+      </span>
+      Ban
+    </button>
+    {{> icons/spinner}}
+  </div>
+  <div id="dialog-error">
+    {{#if error}}
+      <p class="error">{{error}}</p>
+    {{/if}}
+  </div>
+</div>

+ 12 - 0
server/views/partials/links/dialog/ban_success.hbs

@@ -0,0 +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>
+  </div>
+  <p>
+    The link <b>"{{link}}"</b> is banned.
+  </p>
+  <div class="buttons">
+    <button hx-on:click="closeDialog()">Close</button>
+  </div>
+</div>
+

+ 1 - 1
server/views/partials/links/dialog_delete.hbs → server/views/partials/links/dialog/delete.hbs

@@ -1,7 +1,7 @@
 <div class="content">
   <h2>Delete link?</h2>
   <p>
-    Are you sure do you want to delete the link &quot;<span class="link-to-delete">{{link}}</span>&quot;?
+    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>

+ 0 - 0
server/views/partials/links/dialog_delete_success.hbs → server/views/partials/links/dialog/delete_success.hbs


+ 8 - 0
server/views/partials/links/dialog/frame.hbs

@@ -0,0 +1,8 @@
+<div id="link-dialog" class="dialog">
+  <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>
+    </div>
+  </div>
+</div>

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

@@ -1,5 +1,9 @@
 <div class="content">
-  <p>{{message}}</p>
+  {{#if error}}
+    <p>{{error}}</p>
+  {{else}}
+    <p>{{message}}</p>
+  {{/if}}
   <div class="buttons">
     <button hx-on:click="closeDialog()">Close</button>
   </div>

+ 0 - 0
server/views/partials/links/dialog_content/main.hbs


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

@@ -87,10 +87,10 @@
         Close
       </button>
       <button class="primary">
-        <span class="icon reload">
+        <span class="reload">
           {{> icons/reload}}
         </span>
-        <span class="icon loader">
+        <span class="loader">
           {{> icons/spinner}}
         </span>
         Update

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

@@ -23,5 +23,5 @@
     {{> links/tbody}}
     {{> links/tfoot}}
   </table>
-  {{> links/dialog}}
+  {{> links/dialog/frame}}
 </section>

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

@@ -1,5 +1,5 @@
 <tr id="tr-{{id}}" {{#if swap_oob}}hx-swap-oob="true"{{/if}}>
-  <td class="original-url">
+  <td class="original-url right-fade">
     <a href="{{target}}">
       {{target}}
     </a>
@@ -17,17 +17,17 @@
       </p>
     {{/if}}
   </td>
-  <td class="short-link">
-    {{!-- <div class="clipboard">
+  <td class="short-link right-fade">
+    <div class="clipboard small">
       <button 
         aria-label="Copy" 
-        hx-on:click="handleShortURLCopyLink(this);" 
-        data-url="{{url}}"
+        hx-on:click="handleShortURLCopyLink(this);"
+        data-url="{{link.url}}"
       >
-        <svg class="copy" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect width="13" height="13" x="9" y="9" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
+        {{> icons/copy}}
       </button>
-      <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>
-    </div> --}}
+      {{> icons/check}}
+    </div>
     <a href="{{link.url}}">{{link.link}}</a>
   </td>
   <td class="views">

+ 46 - 0
server/views/partials/settings/apikey.hbs

@@ -0,0 +1,46 @@
+<section id="apikey-wrapper">
+  <h2>API</h2>
+  <p>
+    In additional to this website, you can use the API to create, delete and
+    get shortened URLs. If you're not familiar with API, don't generate the key. 
+    DO NOT share this key on the client side of your website. 
+    <a href="https://docs.kutt.it" title="API Docs" target="_blank">
+      Read API docs.
+    </a>
+  </p>
+  <div id="apikey">
+    {{#if user.apikey}}
+      <div class="clipboard small">
+        <button 
+          aria-label="Copy" 
+          hx-on:click="handleShortURLCopyLink(this);" 
+          data-url="{{user.apikey}}"
+        >
+        {{> icons/copy}}
+        </button>
+        {{> icons/check}}
+      </div>
+      <p 
+        hx-on:click="handleShortURLCopyLink(this);" 
+        data-url="{{user.apikey}}"
+      >
+        {{user.apikey}}
+      </p>
+    {{/if}}
+    {{#if error}}
+      <p class="error">{{error}}</p>
+    {{/if}}
+  </div>
+  <form 
+    hx-post="/api/auth/apikey"
+    id="generate-apikey" 
+    hx-target="#apikey-wrapper" 
+    hx-swap="outerHTML"
+  >
+    <button class="secondary">
+      <span>{{> icons/zap}}</span>
+      <span>{{> icons/spinner}}</span>
+      {{#if user.apikey}}Reg{{else}}G{{/if}}enerate key
+    </button>
+  </form>
+</section>

+ 50 - 0
server/views/partials/settings/change_email.hbs

@@ -0,0 +1,50 @@
+<section id="change-email-wrapper">
+  <h2>
+    Change email
+  </h2>
+  <p>Enter your password and a new email address to change your email address.</p>
+  <form 
+    id="change-email"
+    hx-post="/api/auth/change-email"
+    hx-select="form"
+    hx-swap="outerHTML"
+    hx-sync="this:abort"
+  >
+    <div class="inputs">
+      <label class="{{#if errors.password}}error{{/if}}">
+        Passwod:
+        <input 
+          id="password-for-change-email" 
+          name="password" 
+          type="password" 
+          placeholder="Password..."
+          hx-preserve="true"
+        />
+        {{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
+      </label>
+      <label class="{{#if errors.email}}error{{/if}}">
+        New email address:
+        <input 
+          id="email-for-change-email" 
+          name="email" 
+          type="email" 
+          placeholder="john@example.com"
+          hx-preserve="true"
+        />
+        {{#if errors.email}}<p class="error">{{errors.email}}</p>{{/if}}
+      </label>
+    </div>
+    <button class="primary" type="submit">
+      <span>{{> icons/reload}}</span>
+      <span>{{> icons/spinner}}</span>
+      Update
+    </button>
+    {{#if error}}
+      {{#unless errors}}
+        <p class="error">{{error}}</p>
+      {{/unless}}
+    {{else if success}}
+      <p class="success">{{success}}</p>
+    {{/if}}
+  </form>
+</section>

+ 50 - 0
server/views/partials/settings/change_password.hbs

@@ -0,0 +1,50 @@
+<section id="change-password-wrapper">
+  <h2>
+    Change password
+  </h2>
+  <p>Enter your current password and a new password to change it to.</p>
+  <form 
+    id="change-password"
+    hx-post="/api/auth/change-password"
+    hx-select="form"
+    hx-swap="outerHTML"
+    hx-sync="this:abort"
+  >
+    <div class="inputs">
+      <label class="{{#if errors.currentpassword}}error{{/if}}">
+        Current passwod:
+        <input 
+          id="currentpassword" 
+          name="currentpassword" 
+          type="password" 
+          placeholder="Current password..."
+          hx-preserve="true"
+        />
+        {{#if errors.currentpassword}}<p class="error">{{errors.currentpassword}}</p>{{/if}}
+      </label>
+      <label class="{{#if errors.newpassword}}error{{/if}}">
+        New passwod:
+        <input 
+          id="newpassword" 
+          name="newpassword" 
+          type="password" 
+          placeholder="New password..."
+          hx-preserve="true"
+        />
+        {{#if errors.newpassword}}<p class="error">{{errors.newpassword}}</p>{{/if}}
+      </label>
+    </div>
+    <button class="primary" type="submit">
+      <span>{{> icons/reload}}</span>
+      <span>{{> icons/spinner}}</span>
+      Update
+    </button>
+    {{#if error}}
+      {{#unless errors}}
+        <p class="error">{{error}}</p>
+      {{/unless}}
+    {{else if success}}
+      <p class="success">{{success}}</p>
+    {{/if}}
+  </form>
+</section>

+ 42 - 0
server/views/partials/settings/delete_account.hbs

@@ -0,0 +1,42 @@
+<section id="delete-account-wrapper">
+  <h2>
+    Delete account
+  </h2>
+  <p>Delete your account from {{default_domain}}.</p>
+  <form 
+    id="delete-account"
+    hx-post="/api/users/delete"
+    hx-select="form"
+    hx-target="this"
+    hx-swap="outerHTML"
+    hx-sync="this:abort"
+  >
+    {{#if success}}
+      <p class="success">{{success}}</p>
+    {{else}}
+      <div class="inputs">
+        <label class="{{#if errors.password}}error{{/if}}">
+          Password:
+          <input 
+            id="password-for-delete-account" 
+            name="password" 
+            type="password" 
+            placeholder="Password..."
+            hx-preserve="true"
+          />
+          {{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
+        </label>
+      </div>
+      <button class="danger" type="submit">
+        <span>{{> icons/trash}}</span>
+        <span>{{> icons/spinner}}</span>
+        Delete
+      </button>
+      {{#if error}}
+        {{#unless errors}}
+          <p class="error">{{error}}</p>
+        {{/unless}}
+      {{/if}}
+    {{/if}}
+  </form>
+</section>

+ 61 - 0
server/views/partials/settings/domain/add_form.hbs

@@ -0,0 +1,61 @@
+<form 
+  id="add-domain"
+  hx-post="/api/domains"
+  hx-sync="this:abort"
+  hx-swap="outerHTML"
+  hx-on::after-request="
+    document.querySelector('.show-domain-form').classList.remove('hidden');
+    document.querySelector('#add-domain').remove();
+  "
+>
+  <div class="inputs">
+    <label class="{{#if errors.address}}error{{/if}}">
+      Address:
+      <input 
+        id="address" 
+        name="address" 
+        type="text" 
+        placeholder="yoursite.com" 
+        required="true" 
+        hx-preserve="true" 
+
+      />
+      {{#if errors.address}}<p class="error">{{errors.address}}</p>{{/if}}
+    </label>
+    <label class="{{#if errors.homepage}}error{{/if}}">
+      Homepage (Optional):
+      <input 
+        id="homepage" 
+        name="homepage" 
+        placeholder="Homepage URL" 
+        type="text" 
+        hx-preserve="true" 
+
+      />
+      {{#if errors.homepage}}<p class="error">{{errors.homepage}}</p>{{/if}}
+    </label>
+  </div>
+  <p>
+    <small>
+      If you leave homepage empty, <b>yoursite.com</b> will be redirected to <b>{{default_domain}}</b>.
+    </small>
+  </p>
+  <div class="buttons-wrapper">
+    <button type="button" onclick="
+      document.querySelector('.show-domain-form').classList.remove('hidden');
+      document.querySelector('#add-domain').remove();
+    ">
+      Cancel
+    </button>
+    <button type="submit" class="primary">
+      <span>{{> icons/plus}}</span>
+      Add domain
+    </button>
+  </div>
+  {{> icons/spinner}}
+  {{#unless errors}}
+    {{#if error}}
+      <p class="error">{{error}}</p>
+    {{/if}}
+  {{/unless}}
+</form>

+ 28 - 0
server/views/partials/settings/domain/delete.hbs

@@ -0,0 +1,28 @@
+<div class="content">
+  <h2>Delete domain?</h2>
+  <p>
+    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 
+      class="danger confirm" 
+      hx-delete="/api/domains/{id}" 
+      hx-ext="path-params" 
+      hx-vals='{"id":"{{id}}"}' 
+      hx-target="closest .content" 
+      hx-swap="none" 
+      hx-indicator="closest .content"
+      hx-select-oob="#dialog-error"
+    >
+      <span>{{> icons/trash}}</span>
+      Delete
+    </button>
+    {{> icons/spinner}}
+  </div>
+  <div id="dialog-error">
+    {{#if error}}
+      <p class="error">{{error}}</p>
+    {{/if}}
+  </div>
+</div>

+ 12 - 0
server/views/partials/settings/domain/delete_success.hbs

@@ -0,0 +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>
+  </div>
+  <p>
+    Your domain <b>"{{address}}"</b> has been deleted.
+  </p>
+  <div class="buttons">
+    <button hx-on:click="closeDialog()">Close</button>
+  </div>
+</div>
+{{> settings/domain/table}}

+ 8 - 0
server/views/partials/settings/domain/dialog.hbs

@@ -0,0 +1,8 @@
+<div id="domain-dialog" class="dialog">
+  <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>
+    </div>
+  </div>
+</div>

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

@@ -0,0 +1,30 @@
+<h2>
+  Custom domain
+</h2>
+<p>
+  You can set a custom domain for your short URLs, so instead of
+  <b>{{default_domain}}/shorturl</b> you can have
+  <b>yoursite.com/shorturl.</b>
+</p>
+<p>
+  Point your domain's A record to <b>192.64.116.170</b> then add the domain
+  via the form below:
+</p>
+{{> settings/domain/table}}
+<div class="add-domain-wrapper">
+  <button
+    class="secondary show-domain-form"
+    hx-indicator=".add-domain-wrapper"
+    hx-get="/add-domain-form"
+    hx-target="#domain-form-wrapper"
+    hx-swap="innerHTML"
+    hx-on::after-request="event.srcElement.classList.add('hidden')"
+  >
+    <span>{{> icons/plus}}</span>
+    Add domain
+  </button>
+  {{> icons/spinner}}
+  <div id="domain-form-wrapper">
+  </div>
+</div>
+{{> settings/domain/dialog}}

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

@@ -0,0 +1,45 @@
+<table id="domains-table" hx-swap-oob="true">
+  <thead>
+    <tr>
+      <th class="domain">Domain</th>
+      <th class="homepage">Homepage</th>
+      <th class="actions"></th>
+    </tr>
+  </thead>
+  <tbody>
+    {{#if domains}}
+      {{#each domains}}
+        <tr>
+          <td class="domain">
+            {{address}}
+          </td>
+          <td class="homepage">
+            {{#if homepage}}
+              {{homepage}}
+            {{else}}
+              {{@root.default_domain}}
+            {{/if}}
+          </td>
+          <td class="actions">
+            <button 
+              class="action delete" 
+              hx-on:click='openDialog("domain-dialog")' 
+              hx-get="/confirm-domain-delete" 
+              hx-target="#domain-dialog .content-wrapper" 
+              hx-indicator="#domain-dialog" 
+              hx-vals='{"id":"{{id}}"}'
+            >
+              {{> icons/trash}}
+            </button>
+          </td>
+        </tr>
+      {{/each}}
+    {{else}}
+      <tr>
+        <td class="no-entry">
+          No domains yet.
+        </td>
+      </tr>
+    {{/if}}
+  </tbody>
+</table>

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

@@ -7,9 +7,9 @@
           hx-on:click="handleShortURLCopyLink(this);" 
           data-url="{{url}}"
         >
-          <svg class="copy" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect width="13" height="13" x="9" y="9" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
+        {{> icons/copy}}
         </button>
-        <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>
       <h1 
         class="link" 

+ 17 - 0
server/views/settings.hbs

@@ -0,0 +1,17 @@
+{{> header}}
+<section id="settings">
+  <h1 class="settings-welcome">
+    Welcome, <span>{{user.email}}</span>.
+  </h1>
+  <hr />
+  {{> settings/domain/index}}
+  <hr />
+  {{> settings/apikey}}
+  <hr />
+  {{> settings/change_password}}
+  <hr />
+  {{> settings/change_email}}
+  <hr />
+  {{> settings/delete_account}}
+</section>
+{{> footer}}

+ 289 - 27
static/css/styles.css

@@ -47,6 +47,10 @@
   to { transform: translateY(0) }
 }
 
+@keyframes tooltip {
+  to { opacity: 0.9; transform: translate(-50%, 0); }
+}
+
 /* GENERAL */
 body {
   margin: 0;
@@ -67,6 +71,18 @@ body {
   border: none;
 }
 
+.hidden {
+  display: none;
+}
+
+hr {
+  width: 100%;
+  height: 2px;
+  outline: none;
+  border: none;
+  background-color: hsl(200, 20%, 92%);
+}
+
 a {
   color: var(--color-primary);
   border-bottom: 1px dotted transparent;
@@ -175,8 +191,11 @@ button.success:hover {
   box-shadow: 0 6px 15px var(--button-bg-success-box-shadow-color);
 }
 
+button:disabled { cursor: default; }
+button:disabled:hover { transform: none; }
+
 button svg.with-text,
-button span.icon svg {
+button span svg {
   width: 16px;
   height: auto;
   margin-right: 0.5rem;
@@ -191,6 +210,11 @@ button.action {
   box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12);
 }
 
+button.action:disabled {
+  background: none;
+  box-shadow: none;
+}
+
 button.action svg {
   width: 100%;
   margin-right: 0;
@@ -214,6 +238,15 @@ button.action.edit svg {
   stroke: hsl(46, 90%, 50%);
 }
 
+button.action.qrcode {
+  background: hsl(0, 0%, 94%);
+}
+
+button.action.qrcode svg {
+  fill: hsl(0, 0%, 35%);
+  stroke: none;
+}
+
 button.action.stats {
   background: hsl(260, 100%, 96%);
 }
@@ -223,6 +256,21 @@ button.action.stats svg {
   stroke: hsl(260, 100%, 69%);
 }
 
+button.action.ban {
+  background: hsl(10, 100%, 96%);
+}
+
+button.action.ban svg {
+  stroke-width: 2;
+  stroke: hsl(10, 100%, 40%);
+}
+
+button.action.password svg,
+button.action.banned svg {
+  stroke-width: 2.5;
+  stroke: #bbb;
+}
+
 button.table-nav {
   box-sizing: border-box;
   width: auto;
@@ -387,8 +435,8 @@ input[type="checkbox"]:checked:after {
 
 label {
   display: flex;
-  color: #555;
-  font-size: 15px;
+  color: rgb(41, 71, 86);
+  font-size: 1rem;
   flex-direction: column;
   align-items: flex-start;
   font-weight: bold;
@@ -436,13 +484,17 @@ table tr {
 table tr,
 table th,
 table td,
-table tbody,
 table thead,
 table tfoot {
   display: flex;
   overflow: hidden;
 }
 
+table tbody,
+table tr {
+  overflow: visible;
+}
+
 table tbody,
 table thead,
 table tfoot {
@@ -450,13 +502,26 @@ table tfoot {
 }
 
 table tr {
+  padding: 0 0.5rem;
   border-bottom: 1px solid hsl(200, 14%, 94%);
 }
 
+table th,
+table td {
+  flex-basis: 0;
+  padding: 0.75rem;
+}
+
+table td {
+  position: relative;
+  white-space: nowrap;
+  font-size: 15px;
+  align-items: center;
+}
+
 table tbody {
   border-bottom-right-radius: 12px;
   border-bottom-left-radius: 12px;
-  overflow: hidden;
   animation: fadein 0.3s ease-in-out;
 }
 
@@ -522,6 +587,11 @@ table tr.loading-placeholder td {
   animation: slidey 0.2s ease-in-out;
 }
 
+.dialog.qrcode .box {
+  min-width: auto;
+  padding: 2rem;
+}
+
 .dialog .content-wrapper {
   display: flex;
   flex-direction: column;
@@ -559,8 +629,6 @@ table tr.loading-placeholder td {
   margin-top: 0;
 }
 
-.dialog .content .link-to-delete { font-weight: bold; }
-
 .dialog .content .buttons {
   display: flex;
   align-items: center;
@@ -619,6 +687,63 @@ table tr.loading-placeholder td {
 .dialog .content.htmx-request svg.spinner { display: block; }
 .dialog .content.htmx-request button { display: none; }
 
+.inputs {  display: flex;  align-items: flex-start; margin-bottom: 1rem; }
+.inputs label { flex: 0 0 0; margin-right: 1rem; }
+.inputs label:last-child { margin-right: 0; }
+
+[data-tooltip] {
+  position: relative;
+  overflow: visible;
+}
+
+[data-tooltip]:before,
+[data-tooltip]:after {
+  position: absolute;
+  left: 50%;
+  display: none;
+  font-size: 11px;
+  line-height: 1;
+  opacity: 0;
+  transform: translate(-50%, -0.5rem);
+}
+
+[data-tooltip]:before {
+  content: "";
+  border: 4px solid transparent;
+  top: -4px;
+  border-bottom-width: 0;
+  border-top-color: #333;
+  z-index: 1001;
+}
+
+[data-tooltip]:after {
+  content: attr(data-tooltip);
+  top: -25px;
+  text-align: center;
+  min-width: 1rem;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  padding: 5px 7px;
+  border-radius: 4px;
+  box-shadow: 0 1em 2em -0.5em rgba(0, 0, 0, 0.35);
+  background: #333;
+  color: #fff;
+  z-index: 1000;
+}
+
+[data-tooltip]:hover:before,
+[data-tooltip]:hover:after {
+  display: block;
+}
+
+[data-tooltip]:before,
+[data-tooltip]:after,
+[data-tooltip]:hover:before,
+[data-tooltip]:hover:after {
+  animation: tooltip 300ms ease-out forwards;
+}
+
 /* DISTINCT */
 
 .main-wrapper {
@@ -641,10 +766,7 @@ form#login-signup {
   margin: 3rem 0 0;
 }
 
-form#login-signup label {
-  font-size: 16px;
-  margin-bottom: 2rem;
-}
+form#login-signup label { margin-bottom: 2rem; }
 
 form#login-signup input {
   width: 100%;
@@ -805,14 +927,14 @@ main #shorturl h1.link:hover {
   opacity: 0.8;
 }
 
-main #shorturl .clipboard {
+.clipboard {
   width: 35px;
   height: 35px;
   display: flex;
   margin-right: 1rem;
 }
 
-main #shorturl button {
+.clipboard button {
   width: 100%;
   height: 100%;
   display: flex;
@@ -831,26 +953,29 @@ main #shorturl button {
   animation: slidey 0.2s ease-in-out;
 }
 
-main #shorturl button:hover,
-main #shorturl button:focus {
+.clipboard.small { width: 24px; height: 24px; }
+.clipboard.small button { width: 24px; height: 24px; padding: 5px; }
+
+.clipboard button:hover,
+.clipboard button:focus {
   transform: translateY(-2px) scale(1.02, 1.02);
 }
 
-main #shorturl button:focus {
+.clipboard button:focus {
   outline: 3px solid rgba(65, 164, 245, 0.5);
 }
 
-main #shorturl svg {
+.clipboard svg {
   stroke: var(--copy-icon-color);
   width: 100%;
   height: auto;
 }
 
-main #shorturl svg.copy {
+.clipboard svg.copy {
   stroke-width: 2.5;
 }
 
-main #shorturl svg.check {
+.clipboard svg.check {
   display: none;
   padding: 3px;
   stroke-width: 3;
@@ -858,14 +983,14 @@ main #shorturl svg.check {
   animation: slidey 0.2s ease-in-out;
 }
 
-main #shorturl.copied button {
+.clipboard.copied button {
   background-color: transparent;
   box-shadow: none;
 }
 
 
-main #shorturl.copied button { display: none; }
-main #shorturl.copied svg.check { display: block; }
+.clipboard.copied button { display: none; }
+.clipboard.copied svg.check { display: block; }
 
 main #shorturl h1 span {
   border-bottom: 1px dotted #999;
@@ -1020,14 +1145,11 @@ main form label#advanced input {
 
 #links-table-wrapper th,
 #links-table-wrapper td {
-  flex-basis: 0;
   padding: 1rem;
 }
 
 #links-table-wrapper td {
-  white-space: nowrap;
   font-size: 16px;
-  align-items: center;
 }
 
 
@@ -1035,7 +1157,7 @@ main form label#advanced input {
 #links-table-wrapper table .created-at { flex: 2.5 2.5 0; }
 #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; }
+#links-table-wrapper table .actions { flex: 3 3 0; justify-content: flex-end; overflow: visible; }
 #links-table-wrapper table .actions button { margin-right: 0.5rem; }
 #links-table-wrapper table .actions button:last-child { margin-right: 0; }
 
@@ -1120,8 +1242,29 @@ main form label#advanced input {
   margin: 0 1.5rem;
 }
 
+#links-table-wrapper table tbody tr:hover {
+  background-color: hsl(200, 14%, 98%);
+}
+
+#links-table-wrapper table tbody td.right-fade:after {
+  content: "";
+  position: absolute;
+  right: 0;
+  top: 0;
+  height: 100%;
+  width: 16px;
+  background: linear-gradient(to left, white, rgba(255, 255, 255, 0.001));
+}
+
+#links-table-wrapper table tbody tr:hover td.right-fade:after {
+  background: linear-gradient(to left, hsl(200, 14%, 98%), rgba(255, 255, 255, 0.001));
+}
+
+
+#links-table-wrapper table .clipboard { margin-right: 0.5rem; }
+#links-table-wrapper table .clipboard svg.check { width: 24px; }
+
 #links-table-wrapper table tr.edit {
-  border-bottom: 1px solid hsl(200, 14%, 98%);
   background-color: #fafafa;
 }
 
@@ -1195,6 +1338,125 @@ main form label#advanced input {
 
 #links-table-wrapper table tr.edit form .response p { margin: 2rem 0 0; }
 
+.dialog .ban-checklist {
+  display: flex;
+  align-items: center;
+}
+
+.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 */
 
 .introduction {

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/libs/qrcode.min.js


+ 49 - 8
static/scripts/main.js

@@ -6,6 +6,24 @@ document.body.addEventListener('htmx:configRequest', function(evt) {
   evt.detail.headers["Accept"] = "text/html,*/*";
 });
 
+// redirect to homepage
+document.body.addEventListener("redirectToHomepage", function() {
+  setTimeout(() => {
+    window.location.replace("/");
+  }, 1500);
+});
+
+// reset form if event is sent from the backend
+function resetForm(id) {
+  return function() {
+    const form = document.getElementById(id);
+    if (!form) return;
+    form.reset();
+  }
+}
+document.body.addEventListener('resetChangePasswordForm', resetForm("change-password"));
+document.body.addEventListener('resetChangeEmailForm', resetForm("change-email"));
+
 // an htmx extension to use the specifed params in the path instead of the query or body
 htmx.defineExtension("path-params", {
   onEvent: function(name, evt) {
@@ -20,8 +38,8 @@ htmx.defineExtension("path-params", {
 })
 
 // find closest element
-function closest(selector) {
-  let element = this;
+function closest(selector, elm) {
+  let element = elm || this;
 
   while (element && element.nodeType === 1) {
     if (element.matches(selector)) {
@@ -34,6 +52,23 @@ function closest(selector) {
   return null;
 };
 
+// show QR code
+function handleQRCode(element) {
+  const dialog = document.querySelector("#link-dialog");
+  const dialogContent = dialog.querySelector(".content-wrapper");
+  if (!dialogContent) return;
+  openDialog("link-dialog", "qrcode");
+  dialogContent.textContent = "";
+  const qrcode = new QRCode(dialogContent, {
+    text: element.dataset.url,
+    width: 200,
+    height: 200,
+    colorDark : "#000000",
+    colorLight : "#ffffff",
+    correctLevel : QRCode.CorrectLevel.H
+  });   
+}
+
 // copy the link to clipboard
 function handleCopyLink(element) {
   navigator.clipboard.writeText(element.dataset.url);
@@ -42,26 +77,32 @@ function handleCopyLink(element) {
 // copy the link and toggle copy button style
 function handleShortURLCopyLink(element) {
   handleCopyLink(element);
-  const parent = document.querySelector("#shorturl");
-  if (!parent || parent.classList.contains("copied")) return;
-  parent.classList.add("copied");
+  const clipboard = element.parentNode.querySelector(".clipboard") || closest(".clipboard", element);
+  if (!clipboard || clipboard.classList.contains("copied")) return;
+  clipboard.classList.add("copied");
   setTimeout(function() {
-    parent.classList.remove("copied");
+    clipboard.classList.remove("copied");
   }, 1000);
 }
 
 // TODO: make it an extension
 // open and close dialog
-function openDialog(id) {
+function openDialog(id, name) {
   const dialog = document.getElementById(id);
   if (!dialog) return;
   dialog.classList.add("open");
+  if (name) {
+    dialog.classList.add(name);
+  }
 }
 
 function closeDialog() {
   const dialog = document.querySelector(".dialog");
   if (!dialog) return;
-  dialog.classList.remove("open");
+  while (dialog.classList.length > 0) {
+    dialog.classList.remove(dialog.classList[0]);
+  }
+  dialog.classList.add("dialog");
 }
 
 window.addEventListener("click", function(event) {

Някои файлове не бяха показани, защото твърде много файлове са промени