Explorar el Código

more htmx less nextjs

Pouria Ezzati hace 1 año
padre
commit
980610e7a0
Se han modificado 73 ficheros con 2104 adiciones y 645 borrados
  1. 1 1
      package.json
  2. 1 1
      server/config/winston.js
  3. 15 39
      server/handlers/auth.handler.js
  4. 0 0
      server/handlers/domains.handler.ts
  5. 121 0
      server/handlers/helpers.handler.js
  6. 0 87
      server/handlers/helpers.js
  7. 161 89
      server/handlers/links.handler.js
  8. 21 0
      server/handlers/locals.handler.js
  9. 0 13
      server/handlers/types.d.ts
  10. 0 0
      server/handlers/users.handler.js
  11. 76 72
      server/handlers/validators.handler.js
  12. 0 0
      server/models/domain.model.js
  13. 0 0
      server/models/host.model.js
  14. 0 0
      server/models/ip.model.js
  15. 0 0
      server/models/link.model.js
  16. 0 0
      server/models/user.model.js
  17. 0 0
      server/models/visit.model.js
  18. 1 1
      server/passport.js
  19. 0 0
      server/queries/domain.queries.js
  20. 0 0
      server/queries/host.queries.ts
  21. 6 6
      server/queries/index.js
  22. 0 0
      server/queries/ip.queries.js
  23. 11 11
      server/queries/link.queries.js
  24. 0 0
      server/queries/user.queries.js
  25. 0 0
      server/queries/visit.queries.ts
  26. 95 0
      server/renders/renders.handler.js
  27. 27 0
      server/renders/renders.helper.js
  28. 10 11
      server/renders/renders.js
  29. 7 5
      server/routes/auth.routes.js
  30. 6 6
      server/routes/domain.routes.ts
  31. 0 0
      server/routes/health.routes.ts
  32. 35 28
      server/routes/link.routes.js
  33. 8 6
      server/routes/routes.js
  34. 5 5
      server/routes/user.routes.ts
  35. 3 2
      server/server.js
  36. 49 7
      server/utils/utils.js
  37. 9 79
      server/views/homepage.hbs
  38. 0 3
      server/views/layout.hbs
  39. 1 1
      server/views/login.hbs
  40. 7 0
      server/views/logout.hbs
  41. 54 0
      server/views/partials/auth/form.hbs
  42. 0 0
      server/views/partials/auth/verify.hbs
  43. 0 0
      server/views/partials/auth/welcome.hbs
  44. 13 0
      server/views/partials/browser_extensions.hbs
  45. 33 0
      server/views/partials/features.hbs
  46. 20 16
      server/views/partials/header.hbs
  47. 1 0
      server/views/partials/icons/chart.hbs
  48. 1 0
      server/views/partials/icons/pencil.hbs
  49. 2 0
      server/views/partials/icons/reload.hbs
  50. 1 0
      server/views/partials/icons/spinner.hbs
  51. 1 0
      server/views/partials/icons/trash.hbs
  52. 7 0
      server/views/partials/introduction.hbs
  53. 36 0
      server/views/partials/links/actions.hbs
  54. 8 0
      server/views/partials/links/dialog.hbs
  55. 0 0
      server/views/partials/links/dialog_content/main.hbs
  56. 28 0
      server/views/partials/links/dialog_delete.hbs
  57. 12 0
      server/views/partials/links/dialog_delete_success.hbs
  58. 7 0
      server/views/partials/links/dialog_message.hbs
  59. 112 0
      server/views/partials/links/edit.hbs
  60. 16 0
      server/views/partials/links/loading.hbs
  61. 16 0
      server/views/partials/links/nav.hbs
  62. 27 0
      server/views/partials/links/table.hbs
  63. 6 0
      server/views/partials/links/tbody.hbs
  64. 5 0
      server/views/partials/links/tfoot.hbs
  65. 22 0
      server/views/partials/links/thead.hbs
  66. 42 0
      server/views/partials/links/tr.hbs
  67. 0 50
      server/views/partials/login_signup.hbs
  68. 137 0
      server/views/partials/shortener.hbs
  69. 0 7
      server/views/partials/shorturl.hbs
  70. BIN
      static/.DS_Store
  71. 703 98
      static/css/styles.css
  72. 1 0
      static/images/icons/spinner.svg
  73. 117 1
      static/scripts/main.js

+ 1 - 1
package.json

@@ -7,7 +7,7 @@
     "test": "jest --passWithNoTests",
     "docker:build": "docker build -t kutt .",
     "docker:run": "docker run -p 3000:3000 --env-file .env -d kutt:latest",
-    "dev": "node --watch server/server.js",
+    "dev": "node --watch-path=./server server/server.js",
     "dev:backup": "npm run migrate && cross-env NODE_ENV=development nodemon server/server.ts",
     "build": "rimraf production-server && tsc --project tsconfig.json && copyfiles -f \"server/mail/*.html\" production-server/mail && next build client/ ",
     "start": "npm run migrate && cross-env NODE_ENV=production node production-server/server.js",

+ 1 - 1
server/config/winston.js

@@ -36,7 +36,7 @@ const options = {
     colorize: true
   },
   console: {
-    level: "debug",
+    level: "error",
     handleExceptions: true,
     json: false,
     format: combine(colorize(), rawFormat)

+ 15 - 39
server/handlers/auth.js → server/handlers/auth.handler.js

@@ -15,47 +15,25 @@ const env = require("../env");
 function authenticate(type, error, isStrict) {
   return function auth(req, res, next) {
     if (req.user) return next();
-
+    
     passport.authenticate(type, (err, user) => {
       if (err) return next(err);
       const accepts = req.accepts(["json", "html"]);
 
       if (!user && isStrict) {
-        if (accepts === "html") {
-          return utils.sleep(2000).then(() => {
-            return res.render("partials/login_signup", {
-              layout: null,
-              error
-            });
-          });
-        } else {
-          throw new CustomError(error, 401);
-        }
+        req.viewTemplate = "partials/auth/form";
+        throw new CustomError(error, 401);
       }
 
       if (user && isStrict && !user.verified) {
-        const errorMessage = "Your email address is not verified. " +
-          "Sign up to get the verification link again."
-        if (accepts === "html") {
-          return res.render("partials/login_signup", {
-            layout: null,
-            error: errorMessage
-          });
-        } else {
-          throw new CustomError(errorMessage, 400);
-        }
+        req.viewTemplate = "partials/auth/form";
+        throw new CustomError("Your email address is not verified. " +
+          "Sign up to get the verification link again.", 400);
       }
 
       if (user && user.banned) {
-        const errorMessage = "You're banned from using this website.";
-        if (accepts === "html") {
-          return res.render("partials/login_signup", {
-            layout: null,
-            error: errorMessage
-          });
-        } else {
-          throw new CustomError(errorMessage, 403);
-        }
+        req.viewTemplate = "partials/auth/form";
+        throw new CustomError("You're banned from using this website.", 403);
       }
 
       if (user) {
@@ -114,8 +92,6 @@ function admin(req, res, next) {
 async function signup(req, res) {
   const salt = await bcrypt.genSalt(12);
   const password = await bcrypt.hash(req.body.password, salt);
-
-  const accepts = req.accepts(["json", "html"]);
   
   const user = await query.user.add(
     { email: req.body.email, password },
@@ -124,8 +100,9 @@ async function signup(req, res) {
   
   await mail.verification(user);
 
-  if (accepts === "html") {
-    return res.render("partials/signup_verify_email", { layout: null });
+  if (req.isHTML) {
+    res.render("partials/auth/verify");
+    return;
   }
   
   return res.status(201).send({ message: "A verification email has been sent." });
@@ -137,15 +114,14 @@ async function signup(req, res) {
 function login(req, res) {
   const token = utils.signToken(req.user);
 
-  const accepts = req.accepts(["json", "html"]);
-
-  if (accepts === "html") {
+  if (req.isHTML) {
     res.cookie("token", token, {
-      maxAge: 1000 * 60 * 15, // expire after seven days
+      maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
       httpOnly: true,
       secure: env.isProd
     });
-    return res.render("partials/login_welcome", { layout: false });
+    res.render("partials/auth/welcome");
+    return;
   }
   
   return res.status(200).send({ token });

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


+ 121 - 0
server/handlers/helpers.handler.js

@@ -0,0 +1,121 @@
+const { validationResult } = require("express-validator");
+const signale = require("signale");
+
+const { logger } = require("../config/winston");
+const { CustomError } = require("../utils");
+const env = require("../env");
+
+// export const ip: Handler = (req, res, next) => {
+//   req.realIP =
+//     (req.headers["x-real-ip"] as string) || req.connection.remoteAddress || "";
+//   return next();
+// };
+
+/**
+ * @type {import("express").Handler}
+ */
+function isHTML(req, res, next) {
+  const accepts = req.accepts(["json", "html"]);
+  req.isHTML = accepts === "html";
+  next();
+}
+
+/**
+ * @type {import("express").Handler}
+ */
+function noRenderLayout(req, res, next) {
+  res.locals.layout = null;
+  next();
+}
+
+function viewTemplate(template) {
+  return function (req, res, next) {
+    req.viewTemplate = template;
+    next();
+  }
+}
+
+/**
+ * @type {import("express").ErrorRequestHandler}
+ */
+function error(error, req, res, _next) {
+  if (env.isDev) {
+    signale.fatal(error);
+  }
+
+  const message = error instanceof CustomError ? error.message : "An error occurred.";
+  const statusCode = error.statusCode ?? 500;
+
+  if (req.isHTML && req.viewTemplate) {
+    res.render(req.viewTemplate, { error: message });
+    return;
+  }
+
+  return res.status(500).json({ error: message });
+};
+
+
+/**
+ * @type {import("express").Handler}
+ */
+function verify(req, res, next) {
+  const result = validationResult(req);
+  if (result.isEmpty()) return next();
+
+  const errors = result.array();
+  const error = errors[0].msg;
+  
+  res.locals.errors = {};
+  errors.forEach(e => {
+    if (res.locals.errors[e.param]) return;
+    res.locals.errors[e.param] = e.msg;
+  });
+
+
+  throw new CustomError(error, 400);
+}
+
+function query(req, res, next) {
+  const { admin } = req.user || {};
+
+  if (
+    typeof req.query.limit !== "undefined" &&
+    typeof req.query.limit !== "string"
+  ) {
+    return res.status(400).json({ error: "limit query is not valid." });
+  }
+
+  if (
+    typeof req.query.skip !== "undefined" &&
+    typeof req.query.skip !== "string"
+  ) {
+    return res.status(400).json({ error: "skip query is not valid." });
+  }
+
+  if (
+    typeof req.query.search !== "undefined" &&
+    typeof req.query.search !== "string"
+  ) {
+    return res.status(400).json({ error: "search query is not valid." });
+  }
+
+  const limit = parseInt(req.query.limit) || 10;
+  const skip = parseInt(req.query.skip) || 0;
+
+  req.context = {
+    limit: limit > 50 ? 50 : limit,
+    skip,
+    all: admin ? req.query.all === "true" : false
+  };
+
+  next();
+};
+
+module.exports = {
+  error,
+  isHTML,
+  noRenderLayout,
+  query,
+  verify,
+  viewTemplate,
+}

+ 0 - 87
server/handlers/helpers.js

@@ -1,87 +0,0 @@
-const { validationResult } = require("express-validator");
-const signale = require("signale");
-
-const { logger } = require("../config/winston");
-const { CustomError } = require("../utils");
-const env = require("../env");
-
-// export const ip: Handler = (req, res, next) => {
-//   req.realIP =
-//     (req.headers["x-real-ip"] as string) || req.connection.remoteAddress || "";
-//   return next();
-// };
-
-/**
- * @type {import("express").ErrorRequestHandler}
- */
-function error(error, _req, res, _next) {
-  if (env.isDev) {
-    signale.fatal(error);
-  }
-
-  if (error instanceof CustomError) {
-    return res.status(error.statusCode || 500).json({ error: error.message });
-  }
-
-  return res.status(500).json({ error: "An error occurred." });
-};
-
-function verify(template) {
-  return function (req, res, next) {
-    const errors = validationResult(req);
-    if (!errors.isEmpty()) {
-      const accepts = req.accepts(["json", "html"]);
-      const message = errors.array()[0].msg;
-
-      if (template && accepts === "html") {
-        return res.render(template, {
-          layout: null,
-          error: message
-        });
-      }
-      throw new CustomError(message, 400);
-    }
-    return next();
-  }
-}
-
-// export const query: Handler = (req, res, next) => {
-//   const { admin } = req.user || {};
-
-//   if (
-//     typeof req.query.limit !== "undefined" &&
-//     typeof req.query.limit !== "string"
-//   ) {
-//     return res.status(400).json({ error: "limit query is not valid." });
-//   }
-
-//   if (
-//     typeof req.query.skip !== "undefined" &&
-//     typeof req.query.skip !== "string"
-//   ) {
-//     return res.status(400).json({ error: "skip query is not valid." });
-//   }
-
-//   if (
-//     typeof req.query.search !== "undefined" &&
-//     typeof req.query.search !== "string"
-//   ) {
-//     return res.status(400).json({ error: "search query is not valid." });
-//   }
-
-//   const limit = parseInt(req.query.limit) || 10;
-//   const skip = parseInt(req.query.skip) || 0;
-
-//   req.context = {
-//     limit: limit > 50 ? 50 : limit,
-//     skip,
-//     all: admin ? req.query.all === "true" : false
-//   };
-
-//   next();
-// };
-
-module.exports = {
-  error,
-  verify,
-}

+ 161 - 89
server/handlers/links.js → server/handlers/links.handler.js

@@ -4,46 +4,62 @@ const isbot = require("isbot");
 const URL = require("url");
 const dns = require("dns");
 
-const validators = require("./validators");
+const validators = require("./validators.handler");
 // const transporter = require("../mail");
 const query = require("../queries");
 // const queue = require("../queues");
 const utils = require("../utils");
 const env = require("../env");
+const { differenceInSeconds } = require("date-fns");
 
 const CustomError = utils.CustomError;
 const dnsLookup = promisify(dns.lookup);
 
-// export const get: Handler = async (req, res) => {
-//   const { limit, skip, all } = req.context;
-//   const search = req.query.search as string;
-//   const userId = req.user.id;
-
-//   const match = {
-//     ...(!all && { user_id: userId })
-//   };
-
-//   const [links, total] = await Promise.all([
-//     query.link.get(match, { limit, search, skip }),
-//     query.link.total(match, { search })
-//   ]);
+/**
+ * @type {import("express").Handler}
+ */
+async function get(req, res) {
+  const { limit, skip, all } = req.context;
+  const search = req.query.search;
+  const userId = req.user.id;
+
+  const match = {
+    ...(!all && { user_id: userId })
+  };
+
+  const [data, total] = await Promise.all([
+    query.link.get(match, { limit, search, skip }),
+    query.link.total(match, { search })
+  ]);
 
-//   const data = links.map(utils.sanitize.link);
+  const links = data.map(utils.sanitize.link);
+
+  await utils.sleep(1000);
+    
+  if (req.isHTML) {
+    res.render("partials/links/table", {
+      total,
+      limit,
+      skip,
+      links,
+    })
+    return;
+  }
 
-//   return res.send({
-//     total,
-//     limit,
-//     skip,
-//     data
-//   });
-// };
+  return res.send({
+    total,
+    limit,
+    skip,
+    data: links,
+  });
+};
 
 /**
  * @type {import("express").Handler}
  */
 async function create(req, res) {
-  const { reuse, password, customurl, description, target, domain, expire_in } = req.body;
-  const domain_id = domain ? domain.id : null;
+  const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body;
+  const domain_id = fetched_domain ? fetched_domain.id : null;
   
   const targetDomain = utils.removeWww(URL.parse(target).hostname);
   
@@ -75,11 +91,11 @@ async function create(req, res) {
   
   // Check if custom link already exists
   if (queries[4]) {
-    throw new CustomError("Custom URL is already in use.");
+    const error = "Custom URL is already in use.";
+    res.locals.errors = { customurl: error };
+    throw new CustomError(error);
   }
 
-  const accepts = req.accepts(["json", "html"]);
-
   // Create new link
   const address = customurl || queries[5];
   const link = await query.link.create({
@@ -96,10 +112,13 @@ async function create(req, res) {
     query.ip.add(req.realIP);
   }
 
-  if (accepts === "html") {
+  link.domain = fetched_domain?.address;
+  
+  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/shorturl", {
-      layout: null,
+    return res.render("partials/shortener", {
       link: shortURL.link, 
       url: shortURL.url,
     });
@@ -107,75 +126,125 @@ async function create(req, res) {
   
   return res
     .status(201)
-    .send(utils.sanitize.link({ ...link, domain: domain?.address }));
+    .send(utils.sanitize.link({ ...link }));
 }
 
-// export const edit: Handler = async (req, res) => {
-//   const { address, target, description, expire_in, password } = req.body;
-//   if (!address && !target) {
-//     throw new CustomError("Should at least update one field.");
-//   }
+async function edit(req, res) {
+  const { address, target, description, expire_in, password } = req.body;
+  const link = await query.link.find({
+    uuid: req.params.id,
+    ...(!req.user.admin && { user_id: req.user.id })
+  });
 
-//   const link = await query.link.find({
-//     uuid: req.params.id,
-//     ...(!req.user.admin && { user_id: req.user.id })
-//   });
+  if (!link) {
+    throw new CustomError("Link was not found.");
+  }
 
-//   if (!link) {
-//     throw new CustomError("Link was not found.");
-//   }
+  let isChanged = false;
+  [
+    [address, "address"], 
+    [target, "target"], 
+    [description, "description"], 
+    [expire_in, "expire_in"], 
+    [password, "password"]
+  ].forEach(([value, name]) => {
+    if (!value) {
+      delete req.body[name];
+      return;
+    }
+    if (value === link[name]) {
+      delete req.body[name];
+      return;
+    }
+    if (name === "expire_in")
+      if (differenceInSeconds(new Date(value), new Date(link.expire_in)) <= 60) 
+          return;
+    isChanged = true;
+  });
 
-//   const targetDomain = utils.removeWww(URL.parse(target).hostname);
-//   const domain_id = link.domain_id || null;
-
-//   const queries = await Promise.all([
-//     validators.cooldown(req.user),
-//     validators.malware(req.user, target),
-//     address !== link.address &&
-//       query.link.find({
-//         address,
-//         domain_id
-//       }),
-//     validators.bannedDomain(targetDomain),
-//     validators.bannedHost(targetDomain)
-//   ]);
-
-//   // Check if custom link already exists
-//   if (queries[2]) {
-//     throw new CustomError("Custom URL is already in use.");
-//   }
+  await utils.sleep(1000);
+  
+  if (!isChanged) {
+    throw new CustomError("Should at least update one field.");
+  }
 
-//   // Update link
-//   const [updatedLink] = await query.link.update(
-//     {
-//       id: link.id
-//     },
-//     {
-//       ...(address && { address }),
-//       ...(description && { description }),
-//       ...(target && { target }),
-//       ...(expire_in && { expire_in }),
-//       ...(password && { password })
-//     }
-//   );
+  const targetDomain = utils.removeWww(URL.parse(target).hostname);
+  const domain_id = link.domain_id || null;
 
-//   return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
-// };
+  const queries = await Promise.all([
+    validators.cooldown(req.user),
+    target && validators.malware(req.user, target),
+    address && address !== link.address &&
+      query.link.find({
+        address,
+        domain_id
+      }),
+    validators.bannedDomain(targetDomain),
+    validators.bannedHost(targetDomain)
+  ]);
 
-// export const remove: Handler = async (req, res) => {
-//   const link = await query.link.remove({
-//     uuid: req.params.id,
-//     ...(!req.user.admin && { user_id: req.user.id })
-//   });
+  // Check if custom link already exists
+  if (queries[2]) {
+    const error = "Custom URL is already in use.";
+    res.locals.errors = { address: error };
+    throw new CustomError("Custom URL is already in use.");
+  }
 
-//   if (!link) {
-//     throw new CustomError("Could not delete the link");
-//   }
+  // Update link
+  const [updatedLink] = await query.link.update(
+    {
+      id: link.id
+    },
+    {
+      ...(address && { address }),
+      ...(description && { description }),
+      ...(target && { target }),
+      ...(expire_in && { expire_in }),
+      ...(password && { password })
+    }
+  );
+
+  if (req.isHTML) {
+    res.render("partials/links/edit", {
+      swap_oob: true,
+      success: "Link has been updated.",
+      ...utils.sanitize.link({ ...link, ...updatedLink }),
+    });
+    return;
+  }
 
-//   return res
-//     .status(200)
-//     .send({ message: "Link has been deleted successfully." });
-// };
+  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,
+    ...(!req.user.admin && { user_id: req.user.id })
+  });
+
+  if (!isRemoved) {
+    const messsage = error || "Could not delete the link.";
+    throw new CustomError(messsage);
+  }
+
+  await utils.sleep(1000);
+
+  if (req.isHTML) {
+    res.setHeader("HX-Reswap", "outerHTML");
+    res.setHeader("HX-Trigger", "reloadLinks");
+    res.render("partials/links/dialog_delete_success", {
+      link: utils.getShortURL(link.address, link.domain).link,
+    });
+    return;
+  }
+
+  return res
+    .status(200)
+    .send({ message: "Link has been deleted successfully." });
+};
 
 // export const report: Handler = async (req, res) => {
 //   const { link } = req.body;
@@ -400,4 +469,7 @@ async function create(req, res) {
 
 module.exports = {
   create,
+  edit,
+  get,
+  remove,
 }

+ 21 - 0
server/handlers/locals.handler.js

@@ -0,0 +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();
+}
+
+module.exports = {
+  createLink,
+  editLink,
+}

+ 0 - 13
server/handlers/types.d.ts

@@ -1,13 +0,0 @@
-import { Request } from "express";
-
-export interface CreateLinkReq extends Request {
-  body: {
-    reuse?: boolean;
-    password?: string;
-    customurl?: string;
-    description?: string;
-    expire_in?: string;
-    domain?: Domain;
-    target: string;
-  };
-}

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


+ 76 - 72
server/handlers/validators.js → server/handlers/validators.handler.js

@@ -17,6 +17,9 @@ const dnsLookup = promisify(dns.lookup);
 
 const checkUser = (value, { req }) => !!req.user;
 
+let body1;
+let body2;
+
 const createLink = [
   body("target")
     .exists({ checkNull: true, checkFalsy: true })
@@ -50,7 +53,7 @@ const createLink = [
     .isLength({ min: 1, max: 64 })
     .withMessage("Custom URL length must be between 1 and 64.")
     .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
-    .withMessage("Custom URL is not valid")
+    .withMessage("Custom URL is not valid.")
     .custom(value => !preservedURLs.some(url => url.toLowerCase() === value))
     .withMessage("You can't use this custom URL."),
   body("reuse")
@@ -63,8 +66,8 @@ const createLink = [
     .optional({ nullable: true, checkFalsy: true })
     .isString()
     .trim()
-    .isLength({ min: 0, max: 2040 })
-    .withMessage("Description length must be between 0 and 2040."),
+    .isLength({ min: 1, max: 2040 })
+    .withMessage("Description length must be between 1 and 2040."),
   body("expire_in")
     .optional({ nullable: true, checkFalsy: true })
     .isString()
@@ -79,7 +82,7 @@ const createLink = [
     .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
     .customSanitizer(ms)
     .custom(value => value >= ms("1m"))
-    .withMessage("Minimum expire time should be '1 minute'.")
+    .withMessage("Expire time should be more than 1 minute.")
     .customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
   body("domain")
     .optional({ nullable: true, checkFalsy: true })
@@ -88,7 +91,6 @@ const createLink = [
     .isString()
     .withMessage("Domain should be string.")
     .customSanitizer(value => value.toLowerCase())
-    .customSanitizer(value => removeWww(URL.parse(value).hostname || value))
     .custom(async (address, { req }) => {
       if (address === env.DEFAULT_DOMAIN) {
         req.body.domain = null;
@@ -99,70 +101,70 @@ const createLink = [
         address,
         user_id: req.user.id
       });
-      req.body.domain = domain || null;
+      req.body.fetched_domain = domain || null;
 
       if (!domain) return Promise.reject();
     })
     .withMessage("You can't use this domain.")
 ];
 
-// export const editLink = [
-//   body("target")
-//     .optional({ checkFalsy: true, nullable: true })
-//     .isString()
-//     .trim()
-//     .isLength({ min: 1, max: 2040 })
-//     .withMessage("Maximum URL length is 2040.")
-//     .customSanitizer(addProtocol)
-//     .custom(
-//       value =>
-//         urlRegex({ exact: true, strict: false }).test(value) ||
-//         /^(?!https?)(\w+):\/\//.test(value)
-//     )
-//     .withMessage("URL is not valid.")
-//     .custom(value => removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
-//     .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
-//   body("password")
-//     .optional({ nullable: true, checkFalsy: true })
-//     .isString()
-//     .isLength({ min: 3, max: 64 })
-//     .withMessage("Password length must be between 3 and 64."),
-//   body("address")
-//     .optional({ checkFalsy: true, nullable: true })
-//     .isString()
-//     .trim()
-//     .isLength({ min: 1, max: 64 })
-//     .withMessage("Custom URL length must be between 1 and 64.")
-//     .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
-//     .withMessage("Custom URL is not valid")
-//     .custom(value => !preservedURLs.some(url => url.toLowerCase() === value))
-//     .withMessage("You can't use this custom URL."),
-//   body("expire_in")
-//     .optional({ nullable: true, checkFalsy: true })
-//     .isString()
-//     .trim()
-//     .custom(value => {
-//       try {
-//         return !!ms(value);
-//       } catch {
-//         return false;
-//       }
-//     })
-//     .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
-//     .customSanitizer(ms)
-//     .custom(value => value >= ms("1m"))
-//     .withMessage("Minimum expire time should be '1 minute'.")
-//     .customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
-//   body("description")
-//     .optional({ nullable: true, checkFalsy: true })
-//     .isString()
-//     .trim()
-//     .isLength({ min: 0, max: 2040 })
-//     .withMessage("Description length must be between 0 and 2040."),
-//   param("id", "ID is invalid.")
-//     .exists({ checkFalsy: true, checkNull: true })
-//     .isLength({ min: 36, max: 36 })
-// ];
+const editLink = [
+  body("target")
+    .optional({ checkFalsy: true, nullable: true })
+    .isString()
+    .trim()
+    .isLength({ min: 1, max: 2040 })
+    .withMessage("Maximum URL length is 2040.")
+    .customSanitizer(addProtocol)
+    .custom(
+      value =>
+        urlRegex({ exact: true, strict: false }).test(value) ||
+        /^(?!https?)(\w+):\/\//.test(value)
+    )
+    .withMessage("URL is not valid.")
+    .custom(value => removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
+    .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
+  body("password")
+    .optional({ nullable: true, checkFalsy: true })
+    .isString()
+    .isLength({ min: 3, max: 64 })
+    .withMessage("Password length must be between 3 and 64."),
+  body("address")
+    .optional({ checkFalsy: true, nullable: true })
+    .isString()
+    .trim()
+    .isLength({ min: 1, max: 64 })
+    .withMessage("Custom URL length must be between 1 and 64.")
+    .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
+    .withMessage("Custom URL is not valid")
+    .custom(value => !preservedURLs.some(url => url.toLowerCase() === value))
+    .withMessage("You can't use this custom URL."),
+  body("expire_in")
+    .optional({ nullable: true, checkFalsy: true })
+    .isString()
+    .trim()
+    .custom(value => {
+      try {
+        return !!ms(value);
+      } catch {
+        return false;
+      }
+    })
+    .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
+    .customSanitizer(ms)
+    .custom(value => value >= ms("1m"))
+    .withMessage("Expire time should be more than 1 minute.")
+    .customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
+  body("description")
+    .optional({ nullable: true, checkFalsy: true })
+    .isString()
+    .trim()
+    .isLength({ min: 0, max: 2040 })
+    .withMessage("Description length must be between 0 and 2040."),
+  param("id", "ID is invalid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .isLength({ min: 36, max: 36 })
+];
 
 // export const redirectProtected = [
 //   body("password", "Password is invalid.")
@@ -209,14 +211,14 @@ const createLink = [
 //     .isLength({ min: 36, max: 36 })
 // ];
 
-// export const deleteLink = [
-//   param("id", "ID is invalid.")
-//     .exists({
-//       checkFalsy: true,
-//       checkNull: true
-//     })
-//     .isLength({ min: 36, max: 36 })
-// ];
+const deleteLink = [
+  param("id", "ID is invalid.")
+    .exists({
+      checkFalsy: true,
+      checkNull: true
+    })
+    .isLength({ min: 36, max: 36 })
+];
 
 // export const reportLink = [
 //   body("link", "No link has been provided.")
@@ -416,7 +418,7 @@ async function linksCount(user) {
 
   const count = await query.link.total({
     user_id: user.id,
-    created_at: [">", subDays(new Date(), 1).toISOString()]
+    "links.created_at": [">", subDays(new Date(), 1).toISOString()]
   });
 
   if (count > env.USER_LIMIT_PER_DAY) {
@@ -464,6 +466,8 @@ module.exports = {
   checkUser,
   cooldown,
   createLink,
+  deleteLink,
+  editLink,
   linksCount,
   login, 
   malware,

+ 0 - 0
server/models/domain.js → server/models/domain.model.js


+ 0 - 0
server/models/host.js → server/models/host.model.js


+ 0 - 0
server/models/ip.js → server/models/ip.model.js


+ 0 - 0
server/models/link.js → server/models/link.model.js


+ 0 - 0
server/models/user.js → server/models/user.model.js


+ 0 - 0
server/models/visit.js → server/models/visit.model.js


+ 1 - 1
server/passport.js

@@ -8,7 +8,7 @@ const query = require("./queries");
 const env = require("./env");
 
 const jwtOptions = {
-  jwtFromRequest: ExtractJwt.fromHeader("authorization"),
+  jwtFromRequest: req => req.cookies?.token,
   secretOrKey: env.JWT_SECRET
 };
 

+ 0 - 0
server/queries/domain.js → server/queries/domain.queries.js


+ 0 - 0
server/queries/host.ts → server/queries/host.queries.ts


+ 6 - 6
server/queries/index.js

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

+ 0 - 0
server/queries/ip.js → server/queries/ip.queries.js


+ 11 - 11
server/queries/link.js → server/queries/link.queries.js

@@ -44,20 +44,20 @@ function normalizeMatch(match) {
 };
 
 async function total(match, params) {
-  const query = knex("links");
-  Object.entries(match).forEach(([key, value]) => {
-    query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
-  });
+  const query = knex("links")
+    .where(normalizeMatch(match));
   
   if (params?.search) {
     query.andWhereRaw(
-      "links.description || ' '  || links.address || ' ' || target ILIKE '%' || ? || '%'",
+      "concat_ws(' ', description, links.address, target, domains.address) ILIKE '%' || ? || '%'",
       [params.search]
     );
   }
+  query.leftJoin("domains", "links.domain_id", "domains.id");
+  query.count("links.id");
   
-  const [{ count }] = await query.count("id");
-  
+  const [{ count }] = await query;
+
   return typeof count === "number" ? count : parseInt(count);
 }
 
@@ -134,13 +134,13 @@ async function remove(match) {
   const link = await knex("links").where(match).first();
   
   if (!link) {
-    throw new CustomError("Link was not found.");
-  }
-  
+    return { isRemoved: false, error: "Could not find the link.", link: null }
+  };
+
   const deletedLink = await knex("links").where("id", link.id).delete();
   redis.remove.link(link);
   
-  return !!deletedLink;
+  return { isRemoved: !!deletedLink, link };
 }
 
 async function batchRemove(match) {

+ 0 - 0
server/queries/user.js → server/queries/user.queries.js


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


+ 95 - 0
server/renders/renders.handler.js

@@ -0,0 +1,95 @@
+const utils = require("../utils");
+const query = require("../queries")
+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,
+  });
+}
+
+/**
+ * @type {import("express").Handler}
+ */
+function login(req, res) {
+  if (req.user) {
+    return res.redirect("/");
+  }
+  res.render("login", {
+    title: "Log in or sign up"
+  });
+}
+
+/**
+ * @type {import("express").Handler}
+ */
+function logout(req, res) {
+  res.clearCookie("token", { httpOnly: true, secure: env.isProd });
+  res.render("logout", {
+    title: "Logging out.."
+  });
+}
+
+/**
+ * @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,
+      message: "Could not find the link."
+    });
+  }
+  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 linkEdit(req, res) {
+  const link = await query.link.find({
+    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", {
+  //     layout: false,
+  //     message: "Could not find the link."
+  //   });
+  // }
+  res.render("partials/links/edit", {
+    layout: false,
+    ...utils.sanitize.link(link),
+  });
+}
+
+module.exports = {
+  homepage,
+  linkEdit,
+  login,
+  logout,
+  confirmLinkDelete,
+}

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

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

+ 10 - 11
server/renders/renders.js

@@ -1,18 +1,17 @@
+const asyncHandler = require("express-async-handler");
 const { Router } = require("express");
 
+const auth = require("../handlers/auth.handler");
+const renders = require("./renders.handler");
+
 const router = Router();
 
-router.get("/", function homepage(req, res) {
-  console.log(req.cookies);
-  res.render("homepage", {
-    title: "Modern open source URL shortener"
-  });
-});
+router.use(asyncHandler(auth.jwtLoose));
 
-router.get("/login", function login(req, res) {
-  res.render("login", {
-    title: "Log in or sign up"
-  });
-});
+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);
 
 module.exports = router;

+ 7 - 5
server/routes/auth.js → server/routes/auth.routes.js

@@ -1,25 +1,27 @@
 const asyncHandler = require("express-async-handler");
 const { Router } = require("express");
 
-const validators = require("../handlers/validators");
-const helpers = require("../handlers/helpers");
-const auth = require("../handlers/auth");
+const validators = require("../handlers/validators.handler");
+const helpers = require("../handlers/helpers.handler");
+const auth = require("../handlers/auth.handler");
 
 const router = Router();
 
 router.post(
   "/login",
+  helpers.viewTemplate("partials/auth/form"),
   validators.login,
-  asyncHandler(helpers.verify("partials/login_signup")),
+  asyncHandler(helpers.verify),
   asyncHandler(auth.local),
   asyncHandler(auth.login)
 );
 
 router.post(
   "/signup",
+  helpers.viewTemplate("partials/auth/form"),
   auth.signupAccess,
   validators.signup,
-  asyncHandler(helpers.verify("partials/login_signup")),
+  asyncHandler(helpers.verify),
   asyncHandler(auth.signup)
 );
 

+ 6 - 6
server/routes/domains.ts → server/routes/domain.routes.ts

@@ -1,10 +1,10 @@
 import { Router } from "express";
 import asyncHandler from "express-async-handler";
 
-import * as validators from "../handlers/validators";
-import * as helpers from "../handlers/helpers";
-import * as domains from "../handlers/domains";
-import * as auth from "../handlers/auth";
+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();
 
@@ -13,7 +13,7 @@ router.post(
   asyncHandler(auth.apikey),
   asyncHandler(auth.jwt),
   validators.addDomain,
-  asyncHandler(helpers.verify()),
+  asyncHandler(helpers.verify),
   asyncHandler(domains.add)
 );
 
@@ -22,7 +22,7 @@ router.delete(
   asyncHandler(auth.apikey),
   asyncHandler(auth.jwt),
   validators.removeDomain,
-  asyncHandler(helpers.verify()),
+  asyncHandler(helpers.verify),
   asyncHandler(domains.remove)
 );
 

+ 0 - 0
server/routes/health.ts → server/routes/health.routes.ts


+ 35 - 28
server/routes/links.js → server/routes/link.routes.js

@@ -2,51 +2,58 @@ const { Router } = require("express");
 const asyncHandler = require("express-async-handler");
 const cors = require("cors");
 
-const validators = require("../handlers/validators");
+const validators = require("../handlers/validators.handler");
 
-const helpers = require("../handlers/helpers");
-const link = require("../handlers/links");
-const auth = require("../handlers/auth");
+const helpers = require("../handlers/helpers.handler");
+const locals = require("../handlers/locals.handler");
+const link = require("../handlers/links.handler");
+const auth = require("../handlers/auth.handler");
 const env = require("../env");
 
 const router = Router();
 
-// router.get(
-//   "/",
-//   asyncHandler(auth.apikey),
-//   asyncHandler(auth.jwt),
-//   helpers.query,
-//   asyncHandler(link.get)
-// );
+router.get(
+  "/",
+  helpers.viewTemplate("partials/links/table"),
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  helpers.query,
+  asyncHandler(link.get)
+);
 
 router.post(
   "/",
   cors(),
+  helpers.viewTemplate("partials/shortener"),
   asyncHandler(auth.apikey),
   asyncHandler(env.DISALLOW_ANONYMOUS_LINKS ? auth.jwt : auth.jwtLoose),
   asyncHandler(auth.cooldown),
+  locals.createLink,
   validators.createLink,
-  asyncHandler(helpers.verify()),
+  asyncHandler(helpers.verify),
   asyncHandler(link.create)
 );
 
-// router.patch(
-//   "/:id",
-//   asyncHandler(auth.apikey),
-//   asyncHandler(auth.jwt),
-//   validators.editLink,
-//   asyncHandler(helpers.verify),
-//   asyncHandler(link.edit)
-// );
+router.patch(
+  "/:id",
+  helpers.viewTemplate("partials/links/edit"),
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  locals.editLink,
+  validators.editLink,
+  asyncHandler(helpers.verify),
+  asyncHandler(link.edit)
+);
 
-// router.delete(
-//   "/:id",
-//   asyncHandler(auth.apikey),
-//   asyncHandler(auth.jwt),
-//   validators.deleteLink,
-//   asyncHandler(helpers.verify),
-//   asyncHandler(link.remove)
-// );
+router.delete(
+  "/:id",
+  helpers.viewTemplate("partials/links/dialog_delete"),
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  validators.deleteLink,
+  asyncHandler(helpers.verify),
+  asyncHandler(link.remove)
+);
 
 // router.get(
 //   "/:id/stats",

+ 8 - 6
server/routes/routes.js

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

+ 5 - 5
server/routes/users.ts → server/routes/user.routes.ts

@@ -1,10 +1,10 @@
 import { Router } from "express";
 import asyncHandler from "express-async-handler";
 
-import * as validators from "../handlers/validators";
-import * as helpers from "../handlers/helpers";
-import * as user from "../handlers/users";
-import * as auth from "../handlers/auth";
+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();
 
@@ -20,7 +20,7 @@ router.post(
   asyncHandler(auth.apikey),
   asyncHandler(auth.jwt),
   validators.deleteUser,
-  asyncHandler(helpers.verif()),
+  asyncHandler(helpers.verify),
   asyncHandler(user.remove)
 );
 

+ 3 - 2
server/server.js

@@ -9,7 +9,7 @@ const morgan = require("morgan");
 const path = require("path");
 const hbs = require("hbs");
 
-const helpers = require("./handlers/helpers");
+const helpers = require("./handlers/helpers.handler");
 // import * as links from "./handlers/links";
 // import * as auth from "./handlers/auth";
 const routes = require("./routes");
@@ -37,11 +37,12 @@ app.use(express.static("static"));
 
 // app.use(passport.initialize());
 // app.use(helpers.ip);
+app.use(helpers.isHTML);
 
 // template engine / serve html
 app.set("view engine", "hbs");
 app.set("views", path.join(__dirname, "views"));
-utils.extendHbs();
+utils.registerHandlebarsHelpers();
 
 app.use("/", renders);
 

+ 49 - 7
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, addDays } = require("date-fns");
+const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays } = require("date-fns");
 const hbs = require("hbs");
 
 const env = require("../env");
@@ -30,7 +30,6 @@ function signToken(user) {
         iss: "ApiAuth",
         sub: user.email,
         domain: user.domain || "",
-        admin: isAdmin(user.email),
         iat: parseInt((new Date().getTime() / 1000).toFixed(0)),
         exp: parseInt((addDays(new Date(), 7).getTime() / 1000).toFixed(0))
       },
@@ -53,9 +52,9 @@ function addProtocol(url) {
   return hasProtocol ? url : `http://${url}`;
 }
 
-function getShortURL(id, domain) {
+function getShortURL(address, domain) {
   const protocol = env.CUSTOM_DOMAIN_USE_HTTPS || !domain ? "https://" : "http://";
-  const link = `${domain || env.DEFAULT_DOMAIN}/${id}`;
+  const link = `${domain || env.DEFAULT_DOMAIN}/${address}`;
   const url = `${protocol}${link}`;
   return { link, url };
 }
@@ -164,6 +163,42 @@ function getInitStats() {
   });
 }
 
+// format date to relative date
+const MINUTE = 60,
+      HOUR = MINUTE * 60,
+      DAY = HOUR * 24,
+      WEEK = DAY * 7,
+      MONTH = DAY * 30,
+      YEAR = DAY * 365;
+function getTimeAgo(date) {
+  const secondsAgo = Math.round((Date.now() - Number(date)) / 1000);
+
+  if (secondsAgo < MINUTE) {
+    return `${secondsAgo} second${secondsAgo !== 1 ? "s" : ""} ago`;
+  }
+
+  let divisor;
+  let unit = "";
+
+  if (secondsAgo < HOUR) {
+    [divisor, unit] = [MINUTE, "minute"];
+  } else if (secondsAgo < DAY) {
+    [divisor, unit] = [HOUR, "hour"];
+  } else if (secondsAgo < WEEK) {
+    [divisor, unit] = [DAY, "day"];
+  } else if (secondsAgo < MONTH) {
+    [divisor, unit] = [WEEK, "week"];
+  } else if (secondsAgo < YEAR) {
+    [divisor, unit] = [MONTH, "month"];
+  } else {
+    [divisor, unit] = [YEAR, "year"];
+  }
+
+  const count = Math.floor(secondsAgo / divisor);
+  return `${count} ${unit}${count > 1 ? "s" : ""} ago`;
+}
+
+
 const sanitize = {
   domain: domain => ({
     ...domain,
@@ -179,6 +214,8 @@ const sanitize = {
     user_id: undefined,
     uuid: undefined,
     id: link.uuid,
+    relative_created_at: getTimeAgo(link.created_at),
+    relative_expire_in: link.expire_in && ms(differenceInMilliseconds(new Date(link.expire_in), new Date()), { long: true }),
     password: !!link.password,
     link: getShortURL(link.address, link.domain)
   })
@@ -192,8 +229,13 @@ function removeWww(host) {
   return host.replace("www.", "");
 };
 
-function extendHbs() {
+function registerHandlebarsHelpers() {
+  hbs.registerHelper("ifEquals", function(arg1, arg2, options) {
+    return (arg1 === arg2) ? options.fn(this) : options.inverse(this);
+  });
+  
   const blocks = {};
+
   hbs.registerHelper("extend", function(name, context) {
       let block = blocks[name];
       if (!block) {
@@ -214,16 +256,16 @@ module.exports = {
   addProtocol,
   CustomError,
   generateId,
-  getShortURL,
   getDifferenceFunction,
   getInitStats,
   getRedisKey,
+  getShortURL,
   getStatsCacheTime,
   getStatsLimit,
   getUTCDate,
-  extendHbs,
   isAdmin,
   preservedURLs,
+  registerHandlebarsHelpers,
   removeWww,
   sanitize,
   signToken,

+ 9 - 79
server/views/homepage.hbs

@@ -1,81 +1,11 @@
 {{> header}}
-<main>
-  <div id="shorturl">
-    <h1>Kutt your links <span>shorter</span>.</h1>
-  </div>
-  <form hx-post="/api/links" hx-trigger="submit queue:none" hx-target="#shorturl">
-    <div class="target-wrapper">
-      <input
-        id="target"
-        name="target"
-        type="text"
-        placeholder="Paste your long URL"
-        aria-label="target"
-        autofocus="true"
-        data-lpignore="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>
-      </button>
-    </div>
-    <label id="advanced" class="checkbox">
-      <input type="checkbox" />
-      Show advanced options
-    </label>
-  </form>
-</main>
-<section class="introduction">
-  <div class="text-wrapper">
-    <h2>Manage links, set custom <b>domains</b> and view <b>stats</b>.</h2>
-    <a class="button primary">Log in / Sign up</a>
-  </div>
-  <img src="/images/callout.png" alt="callout image" />
-</section>
-<section class="features">
-  <h3>Kutting edge features.</h3>
-  <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>
-      </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>
-      </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>
-      </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>
-      </div>
-      <h4>Free & open source</h4>
-      <p>Completely open source and free. You can host it on your own server.</p>
-    </li>
-  </ul>
-</section>
-<section class="extensions">
-  <h3>Browser extentions.</h3>
-  <div class="extenstions-wrapper">
-    <a class="extension-button chrome" href="https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd" target="_blank" rel="noopener noreferrer" title="Chrome extension">
-      <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M16.2 8.7 23 7a12 12 0 0 1 1.1 5 12 12 0 0 1-13 12l5-8.4.8-1.3a6 6 0 0 0 0-4.7zM13 17.3l-2.1 6.6A12 12 0 0 1 2 5.3l5 8.4c.2.5 1 2.5 3 3.3q1.5.6 3 .3m-1-9.7c-2 0-3.9 1.6-4.3 3.5a5 5 0 0 0 1.2 4 5 5 0 0 0 4.8 1c1.4-.6 2.4-2 2.7-3.4.2-1.9-.8-3.9-2.5-4.7a4 4 0 0 0-2-.4M7 10 2.3 5A12 12 0 0 1 12 0a12 12 0 0 1 10.8 6.7H12.6Q9.8 6.6 8.3 8A5 5 0 0 0 7 10"/></svg>
-      Download for Chrome
-    </a>
-    <a class="extension-button firefox" href="https://addons.mozilla.org/en-US/firefox/addon/kutt/" target="_blank" rel="noopener noreferrer" title="Firefox extension">
-      <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23.4 11v-.4l-.3.3-.3-1.5a10 10 0 0 0-1.3-2.9l-.2-.3-1.5-2q-.6-1-.8-2l-.3 1.3-1.4-1.2C15.8.9 16 0 16 0s-2.8 3.2-1.6 6.4q.6 1.6 2 2.8c1.3 1 2.5 1.7 3.2 3.7q-.9-1.6-2.4-2.5.5 1 .5 2.2a5.3 5.3 0 0 1-6.5 5.2l-1.3-.5q-1-.5-1.6-1.4h.1l.7.2q1.4.2 2.6-.3 1.3-.8 1.8-.7.7 0 .4-.7-.8-1-2-.8c-1 .1-1.7.7-2.8.1H9h.2l-.7-.5h.1l-.7-.7q-.3-.6 0-1.1 0-.3.4-.4h.2l.5.3.3.2v-.1q0-.3-.3-.4l.4.2v-1h.1V10l.2-.2 1-.6.9-.4q.4-.3.5-.8v-.2c0-.2-.3-.3-1.8-.5q-1-.1-1.1-1v.2-.2q.5-1.1 1.5-1.8h-.1l.3-.2-.6-.2-.5.2.2-.2-1.1.5v-.1q-.4.1-.7.5l-.4.3Q6.5 4.7 5.1 5l-.4-.5-.2-.3-.2-.3Q4 3.5 4 2.8q-.5.3-.6.7l-.1.2v-.2q0 .2-.2.3V4v-.1H3a7 7 0 0 0-.6 2.3v.4l-.6.8Q1 8.8.6 10.6l.7-1.2a11 11 0 0 0-.8 4l.3-1.2q0 2.6 1 4.8 1.4 3.3 4.4 5 1.2.9 2.6 1.3l.3.1q1.5.5 3.3.5c4 0 5.3-1.6 5.4-1.7l.5-.7h.2l.2-.1 1.7-1q1.1-1 1.5-2.4.3-.5 0-1l.2-.3q1.2-2 1.4-4.6z"/></svg>
-      Download for Firefox
-    </a>
-  </div>
-</section>
+{{> shortener}}
+{{#if user}}
+  {{> links/table}}
+{{/if}}
+{{#unless user}}
+  {{> introduction}}
+  {{> features}}
+  {{> browser_extensions}}
+{{/unless}}
 {{> footer}}

+ 0 - 3
server/views/layout.hbs

@@ -55,8 +55,5 @@
   {{{block "scripts"}}}
   <script src="/libs/htmx.min.js"></script>
   <script src="/scripts/main.js"></script>
-  <script>
-    htmx.logAll();
-  </script>
 </body>
 </html>

+ 1 - 1
server/views/login.hbs

@@ -1,3 +1,3 @@
 {{> header}}
-{{> login_signup}}
+{{> auth/form}}
 {{> footer}}

+ 7 - 0
server/views/logout.hbs

@@ -0,0 +1,7 @@
+{{> header}}
+<div class="login-signup-message" hx-get="/" hx-trigger="load delay:2s" hx-target="body" hx-push-url="/">
+  <h1>
+    Logged out. Redirecting to homepage...
+  </h1>
+</div>
+{{> footer}}

+ 54 - 0
server/views/partials/auth/form.hbs

@@ -0,0 +1,54 @@
+<form id="login-signup" hx-post="/api/auth/login" hx-swap="outerHTML">
+  <label class="{{#if errors.email}}error{{/if}}">
+    Email address:
+    <input
+      name="email"
+      id="email"
+      type="email"
+      autofocus="true"
+      placeholder="Email address..."
+      hx-preserve="true"
+    />
+    {{#if errors.email}}<p class="error">{{errors.email}}</p>{{/if}}
+  </label>
+  <label  class="{{#if errors.password}}error{{/if}}">
+    Password:
+    <input
+      name="password"
+      id="password"
+      type="password"
+      placeholder="Password..."
+      hx-preserve="true"
+    />
+    {{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
+  </label>
+  {{!-- 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>
+      Log in
+    </button>
+    <button 
+      class="secondary signup" 
+      hx-post="/api/auth/signup" 
+      hx-target="#login-signup" 
+      hx-trigger="click" 
+      hx-indicator="#login-signup" 
+      hx-swap="outerHTML"
+      hx-sync="closest form"
+      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>
+        Sign up
+    </button>
+  </div>
+  <a class="forgot-password" href="/forgot-password" title="Reset password">Forgot your password?</a>
+  {{#unless errors}}
+    {{#if error}}
+      <p class="error">{{error}}</p>
+    {{/if}}
+  {{/unless}}
+</form>

+ 0 - 0
server/views/partials/signup_verify_email.hbs → server/views/partials/auth/verify.hbs


+ 0 - 0
server/views/partials/login_welcome.hbs → server/views/partials/auth/welcome.hbs


+ 13 - 0
server/views/partials/browser_extensions.hbs

@@ -0,0 +1,13 @@
+<section class="extensions">
+  <h3>Browser extentions.</h3>
+  <div class="extenstions-wrapper">
+    <a class="extension-button chrome" href="https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd" target="_blank" rel="noopener noreferrer" title="Chrome extension">
+      <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M16.2 8.7 23 7a12 12 0 0 1 1.1 5 12 12 0 0 1-13 12l5-8.4.8-1.3a6 6 0 0 0 0-4.7zM13 17.3l-2.1 6.6A12 12 0 0 1 2 5.3l5 8.4c.2.5 1 2.5 3 3.3q1.5.6 3 .3m-1-9.7c-2 0-3.9 1.6-4.3 3.5a5 5 0 0 0 1.2 4 5 5 0 0 0 4.8 1c1.4-.6 2.4-2 2.7-3.4.2-1.9-.8-3.9-2.5-4.7a4 4 0 0 0-2-.4M7 10 2.3 5A12 12 0 0 1 12 0a12 12 0 0 1 10.8 6.7H12.6Q9.8 6.6 8.3 8A5 5 0 0 0 7 10"/></svg>
+      Download for Chrome
+    </a>
+    <a class="extension-button firefox" href="https://addons.mozilla.org/en-US/firefox/addon/kutt/" target="_blank" rel="noopener noreferrer" title="Firefox extension">
+      <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23.4 11v-.4l-.3.3-.3-1.5a10 10 0 0 0-1.3-2.9l-.2-.3-1.5-2q-.6-1-.8-2l-.3 1.3-1.4-1.2C15.8.9 16 0 16 0s-2.8 3.2-1.6 6.4q.6 1.6 2 2.8c1.3 1 2.5 1.7 3.2 3.7q-.9-1.6-2.4-2.5.5 1 .5 2.2a5.3 5.3 0 0 1-6.5 5.2l-1.3-.5q-1-.5-1.6-1.4h.1l.7.2q1.4.2 2.6-.3 1.3-.8 1.8-.7.7 0 .4-.7-.8-1-2-.8c-1 .1-1.7.7-2.8.1H9h.2l-.7-.5h.1l-.7-.7q-.3-.6 0-1.1 0-.3.4-.4h.2l.5.3.3.2v-.1q0-.3-.3-.4l.4.2v-1h.1V10l.2-.2 1-.6.9-.4q.4-.3.5-.8v-.2c0-.2-.3-.3-1.8-.5q-1-.1-1.1-1v.2-.2q.5-1.1 1.5-1.8h-.1l.3-.2-.6-.2-.5.2.2-.2-1.1.5v-.1q-.4.1-.7.5l-.4.3Q6.5 4.7 5.1 5l-.4-.5-.2-.3-.2-.3Q4 3.5 4 2.8q-.5.3-.6.7l-.1.2v-.2q0 .2-.2.3V4v-.1H3a7 7 0 0 0-.6 2.3v.4l-.6.8Q1 8.8.6 10.6l.7-1.2a11 11 0 0 0-.8 4l.3-1.2q0 2.6 1 4.8 1.4 3.3 4.4 5 1.2.9 2.6 1.3l.3.1q1.5.5 3.3.5c4 0 5.3-1.6 5.4-1.7l.5-.7h.2l.2-.1 1.7-1q1.1-1 1.5-2.4.3-.5 0-1l.2-.3q1.2-2 1.4-4.6z"/></svg>
+      Download for Firefox
+    </a>
+  </div>
+</section>

+ 33 - 0
server/views/partials/features.hbs

@@ -0,0 +1,33 @@
+<section class="features">
+  <h3>Kutting edge features.</h3>
+  <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>
+      </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>
+      </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>
+      </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>
+      </div>
+      <h4>Free & open source</h4>
+      <p>Completely open source and free. You can host it on your own server.</p>
+    </li>
+  </ul>
+</section>

+ 20 - 16
server/views/partials/header.hbs

@@ -1,4 +1,4 @@
-<header hx-boost="true">
+<header>
   <div class="logo-wrapper">
     <a class="logo nav" href="/" title="Kutt">
       <img src="/images/logo.svg" alt="kutt" width="18" height="24" />
@@ -20,21 +20,25 @@
   </div>
   <nav>
     <ul>
-      <li>
-        <a class="button primary" href="/login" title="Log in or sign up">
-          Log in / Sign up
-        </a>
-      </li>
-      {{!-- <li>
-        <a class="button primary" href="/settings" title="Settings">
-          Settings
-        </a>
-      </li>
-      <li>
-        <a class="nav" href="/logout" title="Log out">
-          Log out
-        </a>
-      </li> --}}
+      {{#unless user}}
+        <li>
+          <a class="button primary" href="/login" title="Log in or sign up">
+            Log in / Sign up
+          </a>
+        </li>
+      {{/unless}}
+      {{#if user}}
+        <li>
+          <a class="button primary" href="/settings" title="Settings">
+            Settings
+          </a>
+        </li>
+        <li>
+          <a class="nav" href="/logout" title="Log out">
+            Log out
+          </a>
+        </li>
+      {{/if}}
     </ul>
   </nav>
 </header>

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M21.2 15.9A10 10 0 1 1 8 2.9M22 12A10 10 0 0 0 12 2v10z"/></svg>

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#5c666b" viewBox="0 0 24 24"><path d="m16 3 5 5L8 21H3v-5z"/></svg>

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

@@ -0,0 +1,2 @@
+
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M1 4v6h6m16 10v-6h-6"/><path d="M20.5 9A9 9 0 0 0 5.6 5.6L1 10m22 4-4.6 4.4A9 9 0 0 1 3.5 15"/></svg>

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

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

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

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

+ 7 - 0
server/views/partials/introduction.hbs

@@ -0,0 +1,7 @@
+<section class="introduction">
+  <div class="text-wrapper">
+    <h2>Manage links, set custom <b>domains</b> and view <b>stats</b>.</h2>
+    <a class="button primary">Log in / Sign up</a>
+  </div>
+  <img src="/images/callout.png" alt="callout image" />
+</section>

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

@@ -0,0 +1,36 @@
+<td class="actions">
+  <button class="action stats">
+    {{> icons/chart}}
+  </button>
+  <button 
+    class="action edit"
+    hx-trigger="click queue:none"
+    hx-ext="path-params"
+    hx-get="/link/edit/{id}" 
+    hx-vals='{"id":"{{id}}"}'
+    hx-swap="beforeend"
+    hx-target="next tr.edit"
+    hx-indicator="next tr.edit"
+    hx-on::before-request="
+      const tr = event.detail.target;
+      tr.classList.add('show');
+      if (tr.querySelector('.content')) {
+        event.preventDefault();
+        tr.classList.remove('show');
+        tr.removeChild(tr.querySelector('.content'));
+      }
+    "
+  >
+    {{> icons/pencil}}
+  </button>
+  <button 
+    class="action delete" 
+    hx-on:click='openDialog("link-dialog")' 
+    hx-get="/confirm-link-delete" 
+    hx-target="#link-dialog .content-wrapper" 
+    hx-indicator="#link-dialog" 
+    hx-vals='{"id":"{{id}}"}'
+  >
+    {{> icons/trash}}
+  </button>
+</td>

+ 8 - 0
server/views/partials/links/dialog.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>

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


+ 28 - 0
server/views/partials/links/dialog_delete.hbs

@@ -0,0 +1,28 @@
+<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;?
+  </p>
+  <div class="buttons">
+    <button hx-on:click="closeDialog()">Cancel</button>
+    <button 
+      class="danger confirm" 
+      hx-delete="/api/links/{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"
+    >
+      <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>
+      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>
+  </div>
+  <div id="dialog-error">
+    {{#if error}}
+      <p class="error">{{error}}</p>
+    {{/if}}
+  </div>
+</div>

+ 12 - 0
server/views/partials/links/dialog_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 link <b>"{{link}}"</b> has been deleted.
+  </p>
+  <div class="buttons">
+    <button hx-on:click="closeDialog()">Close</button>
+  </div>
+</div>
+

+ 7 - 0
server/views/partials/links/dialog_message.hbs

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

+ 112 - 0
server/views/partials/links/edit.hbs

@@ -0,0 +1,112 @@
+<td class="content">
+  <form 
+    id="edit-form-{{id}}"
+    hx-patch="/api/links/{id}"
+    hx-ext="path-params"
+    hx-vals='{"id":"{{id}}"}' 
+    hx-select="form"
+    hx-swap="outerHTML"
+    hx-sync="this:replace"
+    class="{{class}}"
+  >
+    <div>
+      <label class="{{#if errors.target}}error{{/if}}">
+        Target:
+        <input 
+          id="edit-target-{{id}}"
+          name="target" 
+          type="text" 
+          placeholder="Target..." 
+          required="true"
+          value="{{target}}"
+          hx-preserve="true"
+        />
+        {{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
+      </label>
+      <label class="{{#if errors.address}}error{{/if}}">
+        localhost:3000/
+        <input 
+          id="edit-address-{{id}}"
+          name="address" 
+          type="text" 
+          placeholder="Custom URL..." 
+          required="true"
+          value="{{address}}"
+          hx-preserve="true"
+        />
+        {{#if errors.address}}<p class="error">{{errors.address}}</p>{{/if}}
+      </label>
+      <label class="{{#if errors.password}}error{{/if}}">
+        Password:
+        <input 
+          id="edit-password-{{id}}"
+          name="password" 
+          type="password" 
+          placeholder="Password..." 
+          value="{{#if password}}••••••••{{/if}}"
+          hx-preserve="true"
+        />
+        {{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
+      </label>
+    </div>
+    <div>
+      <label class="{{#if errors.description}}error{{/if}}">
+        Description:
+        <input 
+          id="edit-description-{{id}}"
+          name="description" 
+          type="text" 
+          placeholder="Description..." 
+          value="{{description}}"
+          hx-preserve="true"
+        />
+        {{#if errors.description}}<p class="error">{{errors.description}}</p>{{/if}}
+      </label>
+      <label class="{{#if errors.expire_in}}error{{/if}}">
+        Expire in:
+        <input 
+          id="edit-expire_in-{{id}}"
+          name="expire_in" 
+          type="text" 
+          placeholder="2 minutes/hours/days"
+          value="{{relative_expire_in}}"
+          hx-preserve="true"
+        />
+        {{#if errors.expire_in}}<p class="error">{{errors.expire_in}}</p>{{/if}}
+      </label>
+    </div>
+    <div>
+      <button 
+        onclick="
+          const tr = closest('tr');
+          if (!tr) return;
+          tr.classList.remove('show');
+          tr.removeChild(tr.querySelector('.content'));
+        "
+      >
+        Close
+      </button>
+      <button class="primary">
+        <span class="icon reload">
+          {{> icons/reload}}
+        </span>
+        <span class="icon loader">
+          {{> icons/spinner}}
+        </span>
+        Update
+      </button>
+    </div>
+    <div class="response">
+      {{#if error}}
+        {{#unless errors}}
+          <p class="error">{{error}}</p>
+        {{/unless}}
+      {{else if success}}
+        <p class="success">{{success}}</p>
+      {{/if}}
+    </div>
+    <template>
+      {{> links/tr}}
+    </template>
+  </form>
+</td>

+ 16 - 0
server/views/partials/links/loading.hbs

@@ -0,0 +1,16 @@
+{{#unless links}}
+  {{#ifEquals links.length 0}}
+    <tr class="no-links">
+      <td>
+        No links.
+      </td>
+    </tr>
+  {{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>
+        Loading links...
+      </td>
+    </tr>
+  {{/ifEquals}}
+{{/unless}}

+ 16 - 0
server/views/partials/links/nav.hbs

@@ -0,0 +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>
+  </div>
+  <div class="table-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>
+    <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>
+  </div>
+</th>

+ 27 - 0
server/views/partials/links/table.hbs

@@ -0,0 +1,27 @@
+<section id="links-table-wrapper">
+  <h2>Recent shortened links.</h2>
+  <table 
+    hx-get="/api/links"
+    hx-target="tbody"
+    hx-swap="outerHTML" 
+    hx-select="tbody"
+    hx-disinherit="*"
+    hx-include=".links-controls"
+    hx-params="not total"
+    hx-sync="this:replace"
+    hx-select-oob="#total" 
+    hx-trigger="
+      load once, 
+      reloadLinks from:body, 
+      change from:[name='all'], 
+      click delay:100ms from:button.table-nav, 
+      input changed delay:500ms from:[name='search'],
+    "
+    hx-on:htmx:after-on-load="updateLinksNav()"
+  >
+    {{> links/thead}}
+    {{> links/tbody}}
+    {{> links/tfoot}}
+  </table>
+  {{> links/dialog}}
+</section>

+ 6 - 0
server/views/partials/links/tbody.hbs

@@ -0,0 +1,6 @@
+<tbody>
+  {{> links/loading}}
+  {{#each links}}
+    {{> links/tr}}
+  {{/each}}
+</tbody>

+ 5 - 0
server/views/partials/links/tfoot.hbs

@@ -0,0 +1,5 @@
+<tfoot>
+  <tr class="links-controls">
+    {{> links/nav}}
+  </tr>
+</tfoot>

+ 22 - 0
server/views/partials/links/thead.hbs

@@ -0,0 +1,22 @@
+<thead>
+  <tr class="links-controls">
+    <th class="search">
+      <input id="search" name="search" type="text" placeholder="Search..." hx-on:keyup="resetLinkNav()" />
+      <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>
+    </th>
+    {{> links/nav}}
+  </tr>
+  <tr>
+    <th class="original-url">Original URL</th>
+    <th class="created-at">Created at</th>
+    <th class="short-link">Short link</th>
+    <th class="views">Views</th>
+    <th class="actions"></th>
+  </tr>
+</thead>

+ 42 - 0
server/views/partials/links/tr.hbs

@@ -0,0 +1,42 @@
+<tr id="tr-{{id}}" {{#if swap_oob}}hx-swap-oob="true"{{/if}}>
+  <td class="original-url">
+    <a href="{{target}}">
+      {{target}}
+    </a>
+    {{#if description}}
+      <p class="description">
+        {{description}}
+      </p>
+    {{/if}}
+  </td>
+  <td class="created-at">
+    {{relative_created_at}}
+    {{#if relative_expire_in}}
+      <p class="expire-in">
+        Expires in {{relative_expire_in}}
+      </p>
+    {{/if}}
+  </td>
+  <td class="short-link">
+    {{!-- <div class="clipboard">
+      <button 
+        aria-label="Copy" 
+        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>
+      </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> --}}
+    <a href="{{link.url}}">{{link.link}}</a>
+  </td>
+  <td class="views">
+    {{visit_count}}
+  </td>
+    {{> links/actions}}
+</tr>
+<tr class="edit">
+  <td class="loading">
+    {{> icons/spinner}}
+  </td>
+</tr>

+ 0 - 50
server/views/partials/login_signup.hbs

@@ -1,50 +0,0 @@
-<form id="login-signup" hx-post="/api/auth/login" hx-swap="outerHTML">
-  <label>
-    Email address:
-    <input
-      name="email"
-      id="email"
-      type="email"
-      autofocus="true"
-      placeholder="Email address..."
-      hx-preserve="true"
-    />
-  </label>
-  <label>
-    Password:
-    <input
-      name="password"
-      id="password"
-      type="password"
-      placeholder="Password..."
-      hx-preserve="true"
-    />
-  </label>
-  {{!-- TODO: Agree with terms --}}
-  <div class="buttons-wrapper">
-    <button type="submit" class="primary login">
-      <svg class="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="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>
-      Log in
-    </button>
-    <button 
-      class="secondary signup" 
-      hx-post="/api/auth/signup" 
-      hx-target="#login-signup" 
-      hx-trigger="click" 
-      hx-indicator="#login-signup" 
-      hx-swap="outerHTML"
-      hx-sync="closest form"
-      hx-on:htmx:before-request="htmx.addClass('#login-signup', 'signup')"
-      hx-on:htmx:after-request="htmx.removeClass('#login-signup', 'signup')"
-    >
-        <svg class="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="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>
-        Sign up
-    </button>
-  </div>
-  <a class="forgot-password" href="/forgot-password" title="Reset password">Forgot your password?</a>
-  {{#if error}}
-    <p class="error">{{error}}</p>
-  {{/if}}
-</form>

+ 137 - 0
server/views/partials/shortener.hbs

@@ -0,0 +1,137 @@
+<main>
+  <div id="shorturl">
+    {{#if link}}
+      <div class="clipboard">
+        <button 
+          aria-label="Copy" 
+          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>
+        </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>
+      <h1 
+        class="link" 
+        hx-on:click="handleShortURLCopyLink(this);" 
+        data-url="{{url}}"
+      >
+        {{link}}
+      </h1>
+    {{/if}}
+    {{#unless link}}
+        <h1>Kutt your links <span>shorter</span>.</h1>
+    {{/unless}}
+  </div>
+  <form 
+    id="shortener-form" 
+    hx-post="/api/links" 
+    hx-trigger="submit queue:none" 
+    hx-target="closest main" 
+    hx-swap="outerHTML" 
+    autocomplete="off"
+  >
+    <div class="target-wrapper {{#if errors.target}}error{{/if}}">
+      <input
+        id="target"
+        name="target"
+        type="text"
+        placeholder="Paste your long URL"
+        aria-label="target"
+        autofocus="true"
+        data-lpignore="true"
+        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>
+      </button>
+      {{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
+      {{#unless errors}}
+        {{#if error}}
+          <p class="error">{{error}}</p>
+        {{/if}}
+      {{/unless}}
+    </div>
+    <label id="advanced" class="checkbox">
+      <input 
+        name="show_advanced" 
+        type="checkbox" 
+        hx-on:change="htmx.toggleClass('#advanced-options', 'hidden')"
+        {{#if show_advanced}}checked="true"{{/if}}
+      />
+      Show advanced options
+    </label>
+    <section id="advanced-options" class="{{#unless show_advanced}}hidden{{/unless}}">
+      <div class="advanced-input-wrapper">
+        <label class="{{#if errors.domain}}error{{/if}}">
+          Domain:
+          <select 
+            id="domain" 
+            name="domain" 
+            hx-preserve="true" 
+            hx-on:change="
+              const elm = document.querySelector('#customurl-label span');
+              if (!elm) return;
+              elm.textContent = event.target.value + '/';
+            "
+          >
+            <option value={{default_domain}}>{{default_domain}}</option>
+            {{#each domains}}
+              <option value={{address}}>{{address}}</option>
+            {{/each}}
+          </select>
+          {{#if errors.domain}}<p class="error">{{errors.domain}}</p>{{/if}}
+        </label>
+        <label id="customurl-label" class="{{#if errors.customurl}}error{{/if}}">
+          <span id="customurl-label-value" hx-preserve="true">{{default_domain}}/</span>
+          <input
+            type="text" 
+            id="customurl" 
+            name="customurl" 
+            placeholder="Custom address..." 
+            hx-preserve="true"
+            autocomplete="off" 
+          />
+          {{#if errors.customurl}}<p class="error">{{errors.customurl}}</p>{{/if}}
+        </label>
+        <label class="{{#if errors.password}}error{{/if}}">
+          Password:
+          <input 
+            type="password" 
+            id="password" 
+            name="password" 
+            placeholder="Password..." 
+            hx-preserve="true" 
+            autocomplete="off" 
+          />
+          {{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
+        </label>
+      </div>
+      <div class="advanced-input-wrapper">
+        <label class="expire-in {{#if errors.expire_in}}error{{/if}}">
+          Expire in:
+          <input 
+            type="text" 
+            id="expire_in" 
+            name="expire_in" 
+            placeholder="2 minutes/hours/days" 
+            hx-preserve="true" 
+          />
+          {{#if errors.expire_in}}<p class="error">{{errors.expire_in}}</p>{{/if}}
+        </label>
+        <label class="description {{#if errors.description}}error{{/if}}">
+          Description:
+          <input 
+            type="text" 
+            id="description" 
+            name="description" 
+            placeholder="Description..." 
+            hx-preserve="true" 
+          />
+          {{#if errors.description}}<p class="error">{{errors.description}}</p>{{/if}}
+        </label>
+      </div>
+    </section>
+  </form>
+</main>

+ 0 - 7
server/views/partials/shorturl.hbs

@@ -1,7 +0,0 @@
-<div class="clipboard">
-  <button aria-label="Copy" 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>
-  </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>
-<h1 class="link" hx-on:click="handleShortURLCopyLink(this);" data-url="{{url}}">{{link}}</h1>

BIN
static/.DS_Store


+ 703 - 98
static/css/styles.css

@@ -18,10 +18,14 @@
   --button-bg-secondary-box-shadow-color: rgba(81, 45, 168, 0.5);
   --button-bg-danger: linear-gradient(to right, #ee3b3b, #e11c1c);
   --button-bg-danger-box-shadow-color: rgba(168, 45, 45, 0.5);
+  --button-bg-success: linear-gradient(to right, #31b647, #26be3f);
+  --button-bg-success-box-shadow-color: rgba(25, 221, 51, 50%);
   --features-bg: hsl(230, 15%, 92%);
   --extensions-bg: hsl(230, 15%, 20%);
   --send-icon-hover-color: #673ab7;
   --send-spinner-icon-color: hsl(200, 15%, 70%);
+  --success-icon-color: hsl(144, 40%, 57%);
+  --error-icon-color: #f24f4f;
   --copy-icon-color: hsl(144, 40%, 57%);
   --copy-icon-bg-color: hsl(144, 100%, 96%);
   --keyframe-slidey-offset: 0;
@@ -128,6 +132,13 @@ button.danger {
   box-shadow: 0 5px 6px var(--button-bg-danger-box-shadow-color);
 }
 
+a.button.success,
+button.success {
+  color: white;
+  background: var(--button-bg-success);
+  box-shadow: 0 5px 6px var(--button-bg-success-box-shadow-color);
+}
+
 a.button:focus,
 a.button:hover,
 button:focus,
@@ -157,6 +168,102 @@ button.danger:hover {
   box-shadow: 0 6px 15px var(--button-bg-danger-box-shadow-color);
 }
 
+a.button.success:focus,
+a.button.success:hover,
+button.success:focus,
+button.success:hover {
+  box-shadow: 0 6px 15px var(--button-bg-success-box-shadow-color);
+}
+
+button svg.with-text,
+button span.icon svg {
+  width: 16px;
+  height: auto;
+  margin-right: 0.5rem;
+  stroke: white;
+  stroke-width: 2;
+}
+
+button.action {
+  padding: 5px;
+  width: 24px;
+  height: 24px;
+  box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12);
+}
+
+button.action svg {
+  width: 100%;
+  margin-right: 0;
+}
+
+button.action.delete {
+  background: hsl(0, 100%, 96%);
+}
+
+button.action.delete svg {
+  stroke-width: 2;
+  stroke: hsl(0, 100%, 69%);
+}
+
+button.action.edit {
+  background: hsl(46, 100%, 94%);
+}
+
+button.action.edit svg {
+  stroke-width: 2.5;
+  stroke: hsl(46, 90%, 50%);
+}
+
+button.action.stats {
+  background: hsl(260, 100%, 96%);
+}
+
+button.action.stats svg {
+  stroke-width: 2.5;
+  stroke: hsl(260, 100%, 69%);
+}
+
+button.table-nav {
+  box-sizing: border-box;
+  width: auto;
+  height: 28px;
+  display: flex;
+  flex: 0 0 auto;
+  align-items: center;
+  justify-content: center;
+  padding: 0 8px;
+  border: none;
+  border-radius: 4px;
+  box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1);
+  background: none;
+  background-color: white;
+  transition: all 0.2s ease-in-out;
+  font-size: 12px;
+  cursor: pointer;
+}
+
+button.table-nav:disabled {
+  background-color: #f6f6f6;
+  box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
+  opacity: 0.9;
+  color: #bbb;
+  cursor: default;
+}
+
+button.table-nav svg {
+  width: 14px;
+  height: auto;
+}
+
+button.table-nav svg { stroke-width: 2.5; }
+
+button.table-nav:hover { transform: translateY(-2px); }
+button.table-nav:disabled:hover { transform: none; }
+
+svg.spinner {
+  animation: spin 1s linear infinite, fadein 0.3s ease-in-out;
+}
+
 input {
   filter: none;
 }
@@ -165,7 +272,8 @@ input[type="text"],
 input[type="email"],
 input[type="password"] {
   box-sizing: border-box;
-  height: 40px;
+  width: 240px;
+  height: 44px;
   padding: 0 24px;
   font-size: 15px;
   letter-spacing: 0.05em;
@@ -179,6 +287,7 @@ input[type="password"] {
   transition: all 0.5s ease-out;
 }
 
+
 input[type="text"]:focus,
 input[type="email"]:focus,
 input[type="password"]:focus {
@@ -194,6 +303,46 @@ input[type="password"]::placeholder {
   color: #888;
 }
 
+.error input[type="text"],
+.error input[type="email"],
+.error input[type="password"] {
+  border-bottom-color: rgba(250, 10, 10, 0.8);
+  box-shadow: 0 10px 15px hsla(0, 100%, 75%, 0.2);
+}
+
+select {
+  position: relative;
+  width: 240px;
+  height: 44px;
+  padding: 0 24px;
+  font-size: 15px;
+  box-sizing: border-box;
+  letter-spacing: 0.05em;
+  color: #444;
+  background-color: white;
+  box-shadow: 0 10px 35px hsla(200, 15%, 70%, 0.2);
+  border: none;
+  border-radius: 100px;
+  border-bottom: 5px solid #f5f5f5;
+  border-bottom-width: 5px;
+  transition: all 0.5s ease-out;
+  appearance: none;
+  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 24 24' fill='none' stroke='%235c666b' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
+  background-repeat: no-repeat, repeat;
+  background-position: right 1.2em top 50%, 0 0;
+  background-size: 1em auto, 100%;
+}
+
+select:focus {
+  outline: none;
+  box-shadow: 0 20px 35px hsla(200, 15%, 70%, 0.4);
+}
+
+.error select {
+  border-bottom-color: rgba(250, 10, 10, 0.8);
+  box-shadow: 0 10px 15px hsla(0, 100%, 75%, 0.2);
+}
+
 input[type="checkbox"] {
   position: relative;
   width: 1rem;
@@ -236,20 +385,240 @@ input[type="checkbox"]:checked:after {
   transform: translate(-50%, -50%) scale(1);
 }
 
-label.checkbox { 
+label {
   display: flex;
+  color: #555;
+  font-size: 15px;
+  flex-direction: column;
+  align-items: flex-start;
+  font-weight: bold;
+}
+
+label input {
+  margin-top: 0.5rem;
+}
+
+label.checkbox { 
+  flex-direction: row;
   align-items: center;
   cursor: pointer;
+  font-weight: normal;
 }
 
 label.checkbox input[type="checkbox"] {
   margin: 0 0.75rem 2px 0;
 }
 
-label {
-  color: #555;
+p.error,
+p.success {
+  font-weight: normal;
+  animation: fadein 0.3s ease-in-out;
+}
+
+p.error { color: red; }
+p.success { color: #0ea30e; }
+
+table {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  background-color: white;
+  border-radius: 12px;
+  box-shadow: 0 6px 15px hsla(200, 20%, 70%, 0.3);
+  text-align: center;
+  overflow: auto;
+}
+
+table tr {
+  flex: 1 1 auto;
+}
+
+table tr,
+table th,
+table td,
+table tbody,
+table thead,
+table tfoot {
+  display: flex;
+  overflow: hidden;
+}
+
+table tbody,
+table thead,
+table tfoot {
+  flex-direction: column;
+}
+
+table tr {
+  border-bottom: 1px solid hsl(200, 14%, 94%);
+}
+
+table tbody {
+  border-bottom-right-radius: 12px;
+  border-bottom-left-radius: 12px;
+  overflow: hidden;
+  animation: fadein 0.3s ease-in-out;
 }
 
+table tbody + tfoot {
+  border: none;
+}
+
+table thead {
+  background-color: hsl(200, 12%, 95%);
+  border-top-right-radius: 12px;
+  border-top-left-radius: 12px;
+  font-weight: bold;
+}
+
+table thead tr {
+  border-bottom: 1px solid hsl(200, 14%, 90%);
+}
+
+table tfoot {
+  background-color: hsl(200, 12%, 95%);
+  border-bottom-right-radius: 12px;
+  border-bottom-left-radius: 12px;
+}
+
+table tr.loading-placeholder {
+  flex: 1 1 auto;
+  justify-content: center;
+  animation: fadein 0.3s ease-in-out;
+}
+
+table tr.loading-placeholder td {
+  flex: 0 0 auto;
+  font-size: 18px;
+  font-weight: 300;
+}
+
+.dialog {
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  display: none;
+  justify-content: center;
+  align-items: center;
+  background-color: rgba(50, 50, 50, 0.8);
+  z-index: 1000;
+  animation: fadein 0.2s ease-in-out;
+}
+
+.dialog.open { display: flex; }
+
+.dialog .box {
+  min-width: 450px;
+  max-width: 90%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 3rem 2rem;
+  background-color: white;
+  border-radius: 8px;
+  --keyframe-slidey-offset: -30px;
+  animation: slidey 0.2s ease-in-out;
+}
+
+.dialog .content-wrapper {
+  display: flex;
+  flex-direction: column;
+}
+
+.dialog .loading {
+  display: none;
+  width: 24px;
+  height: 24px;
+  margin: 3rem 0;
+  animation: fadein 0.2s ease-in-out;
+}
+
+.dialog.htmx-request .loading {
+  display: block;
+}
+
+.dialog.htmx-request .content-wrapper {
+  display: none;
+}
+
+.dialog .loading svg {
+  animation: spin 1s linear infinite;
+}
+
+.dialog .content {
+  display: flex;
+  flex-direction: column;
+  animation: fadein 0.2s ease-in-out;
+}
+
+.dialog .content h2 {
+  font-weight: bold !important;
+  margin-bottom: 0.5rem !important;
+  margin-top: 0;
+}
+
+.dialog .content .link-to-delete { font-weight: bold; }
+
+.dialog .content .buttons {
+  display: flex;
+  align-items: center;
+  margin-top: 1.5rem;
+}
+
+.dialog .content .buttons button { margin-right: 2rem; }
+.dialog .content .buttons button:last-child { margin-right: 0; }
+
+.dialog .content {
+  align-items: center;
+}
+
+.dialog .content #dialog-error {
+  margin-top: 1rem;
+  margin-bottom: -1rem;
+}
+
+.dialog .content .icon {
+  width: 48px;
+  height: 48px;
+  border-radius: 100%;
+  padding: 5px;
+  margin-bottom: 1.5rem;
+  border: 2px solid;
+}
+
+.dialog .content .icon svg {
+  width: 100%;
+  height: auto;
+}
+
+.dialog .content .icon.success {
+  border-color: var(--success-icon-color);
+}
+
+.dialog .content .icon.success svg {
+  stroke-width: 2;
+  stroke: var(--success-icon-color);
+}
+
+.dialog .content .icon.error {
+  border-color: var(--error-icon-color);
+}
+
+.dialog .content .icon.error svg {
+  stroke-width: 1.5;
+  stroke: var(--error-icon-color);
+}
+
+.dialog .content svg.spinner {
+  display: none;
+  width: 24px;
+  margin: 0.5rem 0;
+}
+.dialog .content.htmx-request svg.spinner { display: block; }
+.dialog .content.htmx-request button { display: none; }
+
 /* DISTINCT */
 
 .main-wrapper {
@@ -261,6 +630,71 @@ label {
   flex-direction: column;
 }
 
+/* LOGIN & SIGNUP */
+
+form#login-signup {
+  max-width: 100%;
+  flex: 1 1 auto;
+  display: flex;
+  flex-direction: column;
+  width: 400px;
+  margin: 3rem 0 0;
+}
+
+form#login-signup label {
+  font-size: 16px;
+  margin-bottom: 2rem;
+}
+
+form#login-signup input {
+  width: 100%;
+  height: 72px;
+  margin-top: 1rem;
+  padding: 0 3rem;
+  font-size: 16px;
+}
+
+form#login-signup .buttons-wrapper {
+  display: flex;
+  align-items: center;
+  margin-bottom: 1.5rem;
+}
+
+form#login-signup .buttons-wrapper button {
+  height: 56px;
+  flex: 1 1 auto;
+  padding: 0 1rem 2px;
+  margin-right: 1rem;
+}
+
+form#login-signup .buttons-wrapper button:last-child { margin-right: 0; }
+
+form#login-signup a.forgot-password {
+  align-self: flex-start;
+  font-size: 14px;
+}
+
+form#login-signup svg.spinner {  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; }
+
+form#login-signup p.error {
+  margin-bottom: 0;
+}
+
+.login-signup-message {
+  flex: 1 1 auto;
+  margin-top: 3rem;
+}
+
+.login-signup-message h1 {
+  font-weight: 300;
+  font-size: 24px;
+}
+
 /* HEADER */
 
 header {
@@ -332,6 +766,7 @@ header nav ul li {
 
 header nav ul li:last-child { margin-left: 0; }
 
+
 /* SHORTENER */
 
 main {
@@ -348,11 +783,12 @@ main {
 main #shorturl {
   display: flex;
   align-items: center;
-  margin-bottom: 3rem;
+  margin: 1rem 0 3rem;
 }
 
 main #shorturl h1 {
-  border-bottom: 1px dotted transparent;
+  margin: 0;
+  border-bottom: 2px dotted transparent;
   font-weight: 300;
   font-size: 2rem;
 }
@@ -360,7 +796,9 @@ main #shorturl h1 {
 main #shorturl h1.link {
   cursor: pointer;
   border-bottom-color: hsl(200, 35%, 65%);
-  transition: opacity 0.2s ease-in-out;
+  transition: opacity 0.3s ease-in-out;
+  --keyframe-slidey-offset: -10px;
+  animation: fadein 0.2s ease-in-out, slidey 0.2s ease-in-out;
 }
 
 main #shorturl h1.link:hover {
@@ -369,12 +807,14 @@ main #shorturl h1.link:hover {
 
 main #shorturl .clipboard {
   width: 35px;
+  height: 35px;
   display: flex;
   margin-right: 1rem;
 }
 
 main #shorturl button {
   width: 100%;
+  height: 100%;
   display: flex;
   margin: 0;
   padding: 7px;
@@ -387,7 +827,7 @@ main #shorturl button {
   transition: transform 0.4s ease-out;
   box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12);
   cursor: pointer;
-  --keyframe-slidey-offset: 10px;
+  --keyframe-slidey-offset: -10px;
   animation: slidey 0.2s ease-in-out;
 }
 
@@ -451,6 +891,16 @@ main form input#target::placeholder {
   font-size: 17px;
 }
 
+main form p.error {
+  font-size: 13px;
+  margin-left: 0.5rem;
+}
+
+main form .target-wrapper p.error {
+  font-size: 15px;
+  margin-left: 1rem;
+}
+
 main form .target-wrapper {
   position: relative;
   width: 100%;
@@ -464,14 +914,13 @@ main form button.submit {
   width: 28px;
   height: auto;
   right: 0;
-  top: 50%;
+  top: 16px;
   padding: 4px;
   margin: 0 2rem 0;
   background: none;
   box-shadow: none;
   outline: none;
   border: none;
-  transform: translateY(-52%);
 }
 
 main form button.submit:focus,
@@ -494,21 +943,258 @@ main form button.submit svg.spinner {
   fill: none;
   stroke: var(--send-spinner-icon-color);
   stroke-width: 2;
-  animation: spin 1s linear infinite, fadein 0.3s ease-in-out;
 }
 
-main form.htmx-request button.submit svg.send {
-  display: none;
+main form.htmx-request button.submit svg.send { display: none; }
+main form.htmx-request button.submit svg.spinner { display: block; }
+
+main form label#advanced {
+  margin-top: 2rem;
+  align-self: flex-start;
 }
 
-main form.htmx-request button.submit svg.spinner {
-  display: block;
+main form label#advanced input {
+  width: 1.1rem;
+  height: 1.1rem;
+  margin-bottom: 2px;
 }
 
-main form label#advanced {
-  margin-top: 2rem;
+#advanced-options {
+  display: flex;
+  flex-direction: column;
+  margin-top: 1.5rem;
+}
+
+#advanced-options.hidden { display: none; }
+
+.advanced-input-wrapper {
+  width: 100%;
+  display: flex;
+  align-items: flex-start;
+  margin-bottom: 1rem;
+}
+
+
+.advanced-input-wrapper label {
+  flex: 1 1 0;
+  padding-right: 1rem;
+}
+
+.advanced-input-wrapper label.expire-in { flex: 1 1 34%; }
+.advanced-input-wrapper label.description { flex: 1 1 65%; }
+
+.advanced-input-wrapper label:last-child { padding-right: 0; }
+
+.advanced-input-wrapper label input,
+.advanced-input-wrapper label select {
+  width: 100%;
+  margin-top: 0.5rem;
+}
+
+/* LINKS TABLE */
+
+#links-table-wrapper {
+  width: 1200px;
+  max-width: 95%;
+  display: flex;
+  flex-direction: column;
+  flex: 1 1 auto;
+  align-items: flex-start;
+  margin: 7rem 0 7.5rem;
+}
+
+#links-table-wrapper h2 {
+  font-weight: 300;
+  margin-bottom: 1rem;
+}
+
+#links-table-wrapper table thead,
+#links-table-wrapper table tbody,
+#links-table-wrapper table tfoot {
+  min-width: 1000px;
+}
+
+#links-table-wrapper tr {
+  padding: 0 0.5rem;
+}
+
+#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;
+}
+
+
+#links-table-wrapper table .original-url { flex: 7 7 0; }
+#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 button { margin-right: 0.5rem; }
+#links-table-wrapper table .actions button:last-child { margin-right: 0; }
+
+#links-table-wrapper table td.original-url,
+#links-table-wrapper table td.created-at { 
+  flex-direction: column;
+  align-items: flex-start;
+  justify-content: center;
+}
+
+#links-table-wrapper table td.original-url p.description,
+#links-table-wrapper table td.created-at p.expire-in { 
+  margin: 0;
+  font-size: 14px;
+  color: #888;
+ }
+
+#links-table-wrapper table tr.no-links {
+  flex: 1 1 auto; 
+  justify-content: center;
+  animation: fadein 0.3s ease-in-out;
+}
+
+#links-table-wrapper table.htmx-request tbody tr { opacity: 0.5; }
+#links-table-wrapper table tr.loading-placeholder { opacity: 0.6 !important; }
+
+#links-table-wrapper table tr.loading-placeholder td,
+#links-table-wrapper table tr.no-links td {
+  flex: 0 0 auto;
+  font-size: 18px;
+  font-weight: 300;
+}
+
+#links-table-wrapper table tr.loading-placeholder svg.spinner {
+  width: 1rem;
+  height: auto;
+  margin-right: 0.5rem;
+  stroke-width: 1.5;
+}
+
+#links-table-wrapper table tr.links-controls { justify-content: space-between; }
+#links-table-wrapper table tfoot tr.links-controls { justify-content: flex-end; }
+
+#links-table-wrapper table th.search,
+#links-table-wrapper table th.nav {
+  flex: 0 0 auto;
+  align-items: center;
+}
+
+#links-table-wrapper table [name="search"] {
+  width: auto;
+  height: 32px;
+  font-size: 14px;
+  padding: 0 1.5rem;
+  border-radius: 3px;
+  border-bottom-width: 2px;
+}
+
+#links-table-wrapper table [name="search"]::placeholder {
+  font-size: 13px;
+}
+
+#links-table-wrapper table tr.links-controls .checkbox {
+  margin-left: 1rem;
+  font-size: 15px;
+}
+
+#links-table-wrapper table .limit,
+#links-table-wrapper table .pagination {
+  display: flex;
+  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 .table-nav-divider {
+  height: 20px;
+  width: 1px;
+  opacity: 0.4;
+  background-color: #888;
+  margin: 0 1.5rem;
 }
 
+#links-table-wrapper table tr.edit {
+  border-bottom: 1px solid hsl(200, 14%, 98%);
+  background-color: #fafafa;
+}
+
+#links-table-wrapper table tr.edit td { 
+  width: 100%;
+  padding: 2rem 1.5rem;
+  flex-basis: auto;
+}
+#links-table-wrapper table tr.edit td form {
+  width: 100;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+}
+
+#links-table-wrapper table tr.edit td form > div {
+  width: 100%;
+  display: flex;
+  align-items: start;
+}
+
+#links-table-wrapper table tr.edit label { margin: 0 0.5rem 1rem; }
+#links-table-wrapper table tr.edit label:first-child { margin-left: 0; }
+#links-table-wrapper table tr.edit label:last-child { margin-right: 0; }
+
+#links-table-wrapper table tr.edit input {
+  height: 44px;
+  padding: 0 1.5rem;
+  font-size: 15px;
+}
+
+#links-table-wrapper table tr.edit input,
+#links-table-wrapper table tr.edit input + p {
+  width: 240px;
+  max-width: 100%;
+  font-size: 14px;
+  text-wrap: wrap;
+  text-align: left;
+}
+
+#links-table-wrapper table tr.edit input[name="target"],
+#links-table-wrapper table tr.edit input[name="description"],
+#links-table-wrapper table tr.edit input[name="target"] + p,
+#links-table-wrapper table tr.edit input[name="description"] + p {
+  width: 420px;
+}
+
+#links-table-wrapper table tr.edit button {
+  height: 38px;
+  margin-right: 1rem;
+}
+
+#links-table-wrapper table tr.edit button:last-child { margin-right: 0; }
+
+#links-table-wrapper table tr.edit form {
+  --keyframe-slidey-offset: -5px;
+  animation: fadein 0.3s ease-in-out, slidey 0.32s ease-in-out;
+}
+
+#links-table-wrapper table tr.edit form.no-animation { animation: none; }
+
+#links-table-wrapper table tr.edit { display: none; }
+#links-table-wrapper table tr.edit.show { display: flex; }
+#links-table-wrapper table tr.edit td.loading { display: none; }
+#links-table-wrapper table tr.edit.htmx-request td.loading { display: block; }
+#links-table-wrapper table tr.edit td.loading svg { width: 16px; height: 16px; }
+
+#links-table-wrapper table tr.edit form.htmx-request button .reload { display: none; }
+#links-table-wrapper table tr.edit form button .loader { display: none; }
+#links-table-wrapper table tr.edit form.htmx-request button .loader { display: inline-block; }
+
+#links-table-wrapper table tr.edit form .response p { margin: 2rem 0 0; }
+
 /* INTRO */
 
 .introduction {
@@ -675,87 +1361,6 @@ main form label#advanced {
 .extensions a.extension-button.chrome svg { fill: #4285f4;  }
 .extensions a.extension-button.firefox svg { fill: #e0890f;  }
 
-/* LOGIN & SIGNUP */
-
-form#login-signup {
-  max-width: 100%;
-  flex: 1 1 auto;
-  display: flex;
-  flex-direction: column;
-  width: 400px;
-  margin: 3rem 0 0;
-}
-
-form#login-signup label {
-  display: flex;
-  flex-direction: column;
-  font-size: 16px;
-  font-weight: bold;
-  margin-bottom: 2rem;
-}
-
-form#login-signup input {
-  width: 100%;
-  height: 72px;
-  margin-top: 1rem;
-  padding: 0 3rem;
-  font-size: 16px;
-}
-
-form#login-signup .buttons-wrapper {
-  display: flex;
-  align-items: center;
-  margin-bottom: 1.5rem;
-}
-
-form#login-signup .buttons-wrapper button {
-  height: 56px;
-  flex: 1 1 auto;
-  padding: 0 1rem 2px;
-  margin-right: 1rem;
-}
-
-form#login-signup .buttons-wrapper button:last-child { margin-right: 0; }
-
-form#login-signup .buttons-wrapper button svg {
-  width: 16px;
-  height: auto;
-  margin-right: 0.5rem;
-  stroke: white;
-  stroke-width: 2;
-}
-
-form#login-signup a.forgot-password {
-  align-self: flex-start;
-  font-size: 14px;
-}
-
-form#login-signup svg.spinner {
-  display: none;
-  animation: fadein 0.3s ease-in-out, spin 1s linear infinite;
-}
-
-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; }
-
-form#login-signup .error {
-  color: red;
-  animation: fadein 0.3s ease-in-out;
-}
-
-.login-signup-message {
-  flex: 1 1 auto;
-  margin-top: 3rem;
-}
-
-.login-signup-message h1 {
-  font-weight: 300;
-  font-size: 24px;
-}
-
 /* FOOTER */
 
 footer {

+ 1 - 0
static/images/icons/spinner.svg

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

+ 117 - 1
static/scripts/main.js

@@ -1,9 +1,39 @@
+// log htmx on dev
+// htmx.logAll();
+
 // add text/html accept header to receive html instead of json for the requests
 document.body.addEventListener('htmx:configRequest', function(evt) {
   evt.detail.headers["Accept"] = "text/html,*/*";
-  console.log(evt.detail.headers);
 });
 
+// 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) {
+    if (name === "htmx:configRequest") {
+      evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function(_, param) {
+        var val = evt.detail.parameters[param]
+        delete evt.detail.parameters[param]
+        return val === undefined ? '{' + param + '}' : encodeURIComponent(val)
+      })
+    }
+  }
+})
+
+// find closest element
+function closest(selector) {
+  let element = this;
+
+  while (element && element.nodeType === 1) {
+    if (element.matches(selector)) {
+      return element;
+    }
+
+    element = element.parentNode;
+  }
+
+  return null;
+};
+
 // copy the link to clipboard
 function handleCopyLink(element) {
   navigator.clipboard.writeText(element.dataset.url);
@@ -18,4 +48,90 @@ function handleShortURLCopyLink(element) {
   setTimeout(function() {
     parent.classList.remove("copied");
   }, 1000);
+}
+
+// TODO: make it an extension
+// open and close dialog
+function openDialog(id) {
+  const dialog = document.getElementById(id);
+  if (!dialog) return;
+  dialog.classList.add("open");
+}
+
+function closeDialog() {
+  const dialog = document.querySelector(".dialog");
+  if (!dialog) return;
+  dialog.classList.remove("open");
+}
+
+window.addEventListener("click", function(event) {
+  const dialog = document.querySelector(".dialog");
+  if (dialog && event.target === dialog) {
+    closeDialog();
+  }
+});
+
+// handle navigation in the table of links
+function setLinksLimit(event) {
+  const buttons = Array.from(document.querySelectorAll('table .nav .limit button'));
+  const limitInput = document.querySelector('#limit');
+  if (!limitInput || !buttons || !buttons.length) return;
+  limitInput.value = event.target.textContent;
+  buttons.forEach(b => {
+    b.disabled = b.textContent === event.target.textContent;
+  });
+}
+
+function setLinksSkip(event, action) {
+  const buttons = Array.from(document.querySelectorAll('table .nav .pagination button'));
+  const limitElm = document.querySelector('#limit');
+  const totalElm = document.querySelector('#total');
+  const skipElm = document.querySelector('#skip');
+  if (!buttons || !limitElm || !totalElm || !skipElm) return;
+  const skip = parseInt(skipElm.value);
+  const limit = parseInt(limitElm.value);
+  const total = parseInt(totalElm.value);
+  skipElm.value = action === "next" ? skip + limit : Math.max(skip - limit, 0);
+  document.querySelectorAll('.pagination .next').forEach(elm => {
+    elm.disabled = total <= parseInt(skipElm.value) + limit;
+  });
+  document.querySelectorAll('.pagination .prev').forEach(elm => {
+    elm.disabled = parseInt(skipElm.value) <= 0;
+  });
+}
+
+function updateLinksNav() {
+  const totalElm = document.querySelector('#total');
+  const skipElm = document.querySelector('#skip');
+  const limitElm = document.querySelector('#limit');
+  if (!totalElm || !skipElm || !limitElm) return;
+  const total = parseInt(totalElm.value);
+  const skip = parseInt(skipElm.value);
+  const limit = parseInt(limitElm.value);
+  document.querySelectorAll('.pagination .next').forEach(elm => {
+    elm.disabled = total <= skip + limit;
+  });
+  document.querySelectorAll('.pagination .prev').forEach(elm => {
+    elm.disabled = skip <= 0;
+  });
+}
+
+function resetLinkNav() {
+  const totalElm = document.querySelector('#total');
+  const skipElm = document.querySelector('#skip');
+  const limitElm = document.querySelector('#limit');
+  if (!totalElm || !skipElm || !limitElm) return;
+  skipElm.value = 0;
+  limitElm.value = 10;
+  const skip = parseInt(skipElm.value);
+  const limit = parseInt(limitElm.value);
+  document.querySelectorAll('.pagination .next').forEach(elm => {
+    elm.disabled = total <= skip + limit;
+  });
+  document.querySelectorAll('.pagination .prev').forEach(elm => {
+    elm.disabled = skip <= 0;
+  });
+  document.querySelectorAll('table .nav .limit button').forEach(b => {
+    b.disabled = b.textContent === limit.toString();
+  });
 }