From 980610e7a0911bac8f957364870b8e7895f5c5b5 Mon Sep 17 00:00:00 2001 From: Pouria Ezzati Date: Wed, 21 Aug 2024 21:22:59 +0330 Subject: [PATCH] more htmx less nextjs --- package.json | 2 +- server/config/winston.js | 2 +- server/handlers/{auth.js => auth.handler.js} | 54 +- .../{domains.ts => domains.handler.ts} | 0 server/handlers/helpers.handler.js | 121 +++ server/handlers/helpers.js | 87 -- .../handlers/{links.js => links.handler.js} | 242 ++++-- server/handlers/locals.handler.js | 21 + server/handlers/types.d.ts | 13 - .../handlers/{users.js => users.handler.js} | 0 .../{validators.js => validators.handler.js} | 148 ++-- server/models/{domain.js => domain.model.js} | 0 server/models/{host.js => host.model.js} | 0 server/models/{ip.js => ip.model.js} | 0 server/models/{link.js => link.model.js} | 0 server/models/{user.js => user.model.js} | 0 server/models/{visit.js => visit.model.js} | 0 server/passport.js | 2 +- .../queries/{domain.js => domain.queries.js} | 0 server/queries/{host.ts => host.queries.ts} | 0 server/queries/index.js | 12 +- server/queries/{ip.js => ip.queries.js} | 0 server/queries/{link.js => link.queries.js} | 22 +- server/queries/{user.js => user.queries.js} | 0 server/queries/{visit.ts => visit.queries.ts} | 0 server/renders/renders.handler.js | 95 +++ server/renders/renders.helper.js | 27 + server/renders/renders.js | 21 +- server/routes/{auth.js => auth.routes.js} | 12 +- .../routes/{domains.ts => domain.routes.ts} | 12 +- server/routes/{health.ts => health.routes.ts} | 0 server/routes/{links.js => link.routes.js} | 63 +- server/routes/routes.js | 14 +- server/routes/{users.ts => user.routes.ts} | 10 +- server/server.js | 5 +- server/utils/utils.js | 56 +- server/views/homepage.hbs | 88 +- server/views/layout.hbs | 3 - server/views/login.hbs | 2 +- server/views/logout.hbs | 7 + server/views/partials/auth/form.hbs | 54 ++ .../verify.hbs} | 0 .../{login_welcome.hbs => auth/welcome.hbs} | 0 server/views/partials/browser_extensions.hbs | 13 + server/views/partials/features.hbs | 33 + server/views/partials/header.hbs | 36 +- server/views/partials/icons/chart.hbs | 1 + server/views/partials/icons/pencil.hbs | 1 + server/views/partials/icons/reload.hbs | 2 + server/views/partials/icons/spinner.hbs | 1 + server/views/partials/icons/trash.hbs | 1 + server/views/partials/introduction.hbs | 7 + server/views/partials/links/actions.hbs | 36 + server/views/partials/links/dialog.hbs | 8 + .../partials/links/dialog_content/main.hbs | 0 server/views/partials/links/dialog_delete.hbs | 28 + .../partials/links/dialog_delete_success.hbs | 12 + .../views/partials/links/dialog_message.hbs | 7 + server/views/partials/links/edit.hbs | 112 +++ server/views/partials/links/loading.hbs | 16 + server/views/partials/links/nav.hbs | 16 + server/views/partials/links/table.hbs | 27 + server/views/partials/links/tbody.hbs | 6 + server/views/partials/links/tfoot.hbs | 5 + server/views/partials/links/thead.hbs | 22 + server/views/partials/links/tr.hbs | 42 + server/views/partials/login_signup.hbs | 50 -- server/views/partials/shortener.hbs | 137 +++ server/views/partials/shorturl.hbs | 7 - static/.DS_Store | Bin 0 -> 8196 bytes static/css/styles.css | 803 +++++++++++++++--- static/images/icons/spinner.svg | 1 + static/scripts/main.js | 118 ++- 73 files changed, 2101 insertions(+), 642 deletions(-) rename server/handlers/{auth.js => auth.handler.js} (84%) rename server/handlers/{domains.ts => domains.handler.ts} (100%) create mode 100644 server/handlers/helpers.handler.js delete mode 100644 server/handlers/helpers.js rename server/handlers/{links.js => links.handler.js} (65%) create mode 100644 server/handlers/locals.handler.js delete mode 100644 server/handlers/types.d.ts rename server/handlers/{users.js => users.handler.js} (100%) rename server/handlers/{validators.js => validators.handler.js} (81%) rename server/models/{domain.js => domain.model.js} (100%) rename server/models/{host.js => host.model.js} (100%) rename server/models/{ip.js => ip.model.js} (100%) rename server/models/{link.js => link.model.js} (100%) rename server/models/{user.js => user.model.js} (100%) rename server/models/{visit.js => visit.model.js} (100%) rename server/queries/{domain.js => domain.queries.js} (100%) rename server/queries/{host.ts => host.queries.ts} (100%) rename server/queries/{ip.js => ip.queries.js} (100%) rename server/queries/{link.js => link.queries.js} (90%) rename server/queries/{user.js => user.queries.js} (100%) rename server/queries/{visit.ts => visit.queries.ts} (100%) create mode 100644 server/renders/renders.handler.js create mode 100644 server/renders/renders.helper.js rename server/routes/{auth.js => auth.routes.js} (75%) rename server/routes/{domains.ts => domain.routes.ts} (58%) rename server/routes/{health.ts => health.routes.ts} (100%) rename server/routes/{links.js => link.routes.js} (52%) rename server/routes/{users.ts => user.routes.ts} (59%) create mode 100644 server/views/logout.hbs create mode 100644 server/views/partials/auth/form.hbs rename server/views/partials/{signup_verify_email.hbs => auth/verify.hbs} (100%) rename server/views/partials/{login_welcome.hbs => auth/welcome.hbs} (100%) create mode 100644 server/views/partials/browser_extensions.hbs create mode 100644 server/views/partials/features.hbs create mode 100644 server/views/partials/icons/chart.hbs create mode 100644 server/views/partials/icons/pencil.hbs create mode 100644 server/views/partials/icons/reload.hbs create mode 100644 server/views/partials/icons/spinner.hbs create mode 100644 server/views/partials/icons/trash.hbs create mode 100644 server/views/partials/introduction.hbs create mode 100644 server/views/partials/links/actions.hbs create mode 100644 server/views/partials/links/dialog.hbs create mode 100644 server/views/partials/links/dialog_content/main.hbs create mode 100644 server/views/partials/links/dialog_delete.hbs create mode 100644 server/views/partials/links/dialog_delete_success.hbs create mode 100644 server/views/partials/links/dialog_message.hbs create mode 100644 server/views/partials/links/edit.hbs create mode 100644 server/views/partials/links/loading.hbs create mode 100644 server/views/partials/links/nav.hbs create mode 100644 server/views/partials/links/table.hbs create mode 100644 server/views/partials/links/tbody.hbs create mode 100644 server/views/partials/links/tfoot.hbs create mode 100644 server/views/partials/links/thead.hbs create mode 100644 server/views/partials/links/tr.hbs delete mode 100644 server/views/partials/login_signup.hbs create mode 100644 server/views/partials/shortener.hbs delete mode 100644 server/views/partials/shorturl.hbs create mode 100644 static/.DS_Store create mode 100644 static/images/icons/spinner.svg diff --git a/package.json b/package.json index 7579b34..6b23c82 100644 --- a/package.json +++ b/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", diff --git a/server/config/winston.js b/server/config/winston.js index b0f4745..7a6312c 100644 --- a/server/config/winston.js +++ b/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) diff --git a/server/handlers/auth.js b/server/handlers/auth.handler.js similarity index 84% rename from server/handlers/auth.js rename to server/handlers/auth.handler.js index 7161f2d..c104dd1 100644 --- a/server/handlers/auth.js +++ b/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 }); diff --git a/server/handlers/domains.ts b/server/handlers/domains.handler.ts similarity index 100% rename from server/handlers/domains.ts rename to server/handlers/domains.handler.ts diff --git a/server/handlers/helpers.handler.js b/server/handlers/helpers.handler.js new file mode 100644 index 0000000..7936d8c --- /dev/null +++ b/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, +} \ No newline at end of file diff --git a/server/handlers/helpers.js b/server/handlers/helpers.js deleted file mode 100644 index 3b69afd..0000000 --- a/server/handlers/helpers.js +++ /dev/null @@ -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, -} \ No newline at end of file diff --git a/server/handlers/links.js b/server/handlers/links.handler.js similarity index 65% rename from server/handlers/links.js rename to server/handlers/links.handler.js index 1f5703d..883bad6 100644 --- a/server/handlers/links.js +++ b/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; +/** + * @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 match = { + ...(!all && { user_id: userId }) + }; -// const [links, total] = await Promise.all([ -// query.link.get(match, { limit, search, skip }), -// query.link.total(match, { search }) -// ]); + 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); -// return res.send({ -// total, -// limit, -// skip, -// data -// }); -// }; + await utils.sleep(1000); + + if (req.isHTML) { + res.render("partials/links/table", { + total, + limit, + skip, + links, + }) + return; + } + + 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; + await utils.sleep(1000); + + if (!isChanged) { + throw new CustomError("Should at least update one field."); + } -// 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) -// ]); + const targetDomain = utils.removeWww(URL.parse(target).hostname); + const domain_id = link.domain_id || null; -// // Check if custom link already exists -// if (queries[2]) { -// throw new CustomError("Custom URL is already in use."); -// } + 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) + ]); -// // Update link -// const [updatedLink] = await query.link.update( -// { -// id: link.id -// }, -// { -// ...(address && { address }), -// ...(description && { description }), -// ...(target && { target }), -// ...(expire_in && { expire_in }), -// ...(password && { password }) -// } -// ); + // 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."); + } -// return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink })); -// }; + // Update link + const [updatedLink] = await query.link.update( + { + id: link.id + }, + { + ...(address && { address }), + ...(description && { description }), + ...(target && { target }), + ...(expire_in && { expire_in }), + ...(password && { password }) + } + ); -// 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 }) -// }); + if (req.isHTML) { + res.render("partials/links/edit", { + swap_oob: true, + success: "Link has been updated.", + ...utils.sanitize.link({ ...link, ...updatedLink }), + }); + return; + } -// if (!link) { -// throw new CustomError("Could not delete the link"); -// } + return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink })); +}; -// return res -// .status(200) -// .send({ message: "Link has been deleted successfully." }); -// }; +/** + * @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, } \ No newline at end of file diff --git a/server/handlers/locals.handler.js b/server/handlers/locals.handler.js new file mode 100644 index 0000000..22b484a --- /dev/null +++ b/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, +} \ No newline at end of file diff --git a/server/handlers/types.d.ts b/server/handlers/types.d.ts deleted file mode 100644 index 03ce1e7..0000000 --- a/server/handlers/types.d.ts +++ /dev/null @@ -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; - }; -} diff --git a/server/handlers/users.js b/server/handlers/users.handler.js similarity index 100% rename from server/handlers/users.js rename to server/handlers/users.handler.js diff --git a/server/handlers/validators.js b/server/handlers/validators.handler.js similarity index 81% rename from server/handlers/validators.js rename to server/handlers/validators.handler.js index 013072c..3615239 100644 --- a/server/handlers/validators.js +++ b/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, diff --git a/server/models/domain.js b/server/models/domain.model.js similarity index 100% rename from server/models/domain.js rename to server/models/domain.model.js diff --git a/server/models/host.js b/server/models/host.model.js similarity index 100% rename from server/models/host.js rename to server/models/host.model.js diff --git a/server/models/ip.js b/server/models/ip.model.js similarity index 100% rename from server/models/ip.js rename to server/models/ip.model.js diff --git a/server/models/link.js b/server/models/link.model.js similarity index 100% rename from server/models/link.js rename to server/models/link.model.js diff --git a/server/models/user.js b/server/models/user.model.js similarity index 100% rename from server/models/user.js rename to server/models/user.model.js diff --git a/server/models/visit.js b/server/models/visit.model.js similarity index 100% rename from server/models/visit.js rename to server/models/visit.model.js diff --git a/server/passport.js b/server/passport.js index 621484b..d06dd1c 100644 --- a/server/passport.js +++ b/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 }; diff --git a/server/queries/domain.js b/server/queries/domain.queries.js similarity index 100% rename from server/queries/domain.js rename to server/queries/domain.queries.js diff --git a/server/queries/host.ts b/server/queries/host.queries.ts similarity index 100% rename from server/queries/host.ts rename to server/queries/host.queries.ts diff --git a/server/queries/index.js b/server/queries/index.js index 6b3272a..15d0fe8 100644 --- a/server/queries/index.js +++ b/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, diff --git a/server/queries/ip.js b/server/queries/ip.queries.js similarity index 100% rename from server/queries/ip.js rename to server/queries/ip.queries.js diff --git a/server/queries/link.js b/server/queries/link.queries.js similarity index 90% rename from server/queries/link.js rename to server/queries/link.queries.js index 32729c3..427506b 100644 --- a/server/queries/link.js +++ b/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) { diff --git a/server/queries/user.js b/server/queries/user.queries.js similarity index 100% rename from server/queries/user.js rename to server/queries/user.queries.js diff --git a/server/queries/visit.ts b/server/queries/visit.queries.ts similarity index 100% rename from server/queries/visit.ts rename to server/queries/visit.queries.ts diff --git a/server/renders/renders.handler.js b/server/renders/renders.handler.js new file mode 100644 index 0000000..4c2cb41 --- /dev/null +++ b/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, +} \ No newline at end of file diff --git a/server/renders/renders.helper.js b/server/renders/renders.helper.js new file mode 100644 index 0000000..64234aa --- /dev/null +++ b/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, +} \ No newline at end of file diff --git a/server/renders/renders.js b/server/renders/renders.js index 4ec2887..04d4848 100644 --- a/server/renders/renders.js +++ b/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; diff --git a/server/routes/auth.js b/server/routes/auth.routes.js similarity index 75% rename from server/routes/auth.js rename to server/routes/auth.routes.js index 1a160ca..1c78963 100644 --- a/server/routes/auth.js +++ b/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) ); diff --git a/server/routes/domains.ts b/server/routes/domain.routes.ts similarity index 58% rename from server/routes/domains.ts rename to server/routes/domain.routes.ts index b70c1b1..ab086f1 100644 --- a/server/routes/domains.ts +++ b/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) ); diff --git a/server/routes/health.ts b/server/routes/health.routes.ts similarity index 100% rename from server/routes/health.ts rename to server/routes/health.routes.ts diff --git a/server/routes/links.js b/server/routes/link.routes.js similarity index 52% rename from server/routes/links.js rename to server/routes/link.routes.js index 1ae1e6b..4742021 100644 --- a/server/routes/links.js +++ b/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", diff --git a/server/routes/routes.js b/server/routes/routes.js index b9ec0d5..36a308d 100644 --- a/server/routes/routes.js +++ b/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); diff --git a/server/routes/users.ts b/server/routes/user.routes.ts similarity index 59% rename from server/routes/users.ts rename to server/routes/user.routes.ts index 220434e..306485f 100644 --- a/server/routes/users.ts +++ b/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) ); diff --git a/server/server.js b/server/server.js index 34db1ed..c7fae98 100644 --- a/server/server.js +++ b/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); diff --git a/server/utils/utils.js b/server/utils/utils.js index f285e18..2a4ec89 100644 --- a/server/utils/utils.js +++ b/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, diff --git a/server/views/homepage.hbs b/server/views/homepage.hbs index 0878f42..b98b62a 100644 --- a/server/views/homepage.hbs +++ b/server/views/homepage.hbs @@ -1,81 +1,11 @@ {{> header}} -
-
-

Kutt your links shorter.

-
-
-
- - -
- -
-
-
-
-

Manage links, set custom domains and view stats.

- Log in / Sign up -
- callout image -
-
-

Kutting edge features.

- -
-
-

Browser extentions.

- -
+{{> shortener}} +{{#if user}} + {{> links/table}} +{{/if}} +{{#unless user}} + {{> introduction}} + {{> features}} + {{> browser_extensions}} +{{/unless}} {{> footer}} diff --git a/server/views/layout.hbs b/server/views/layout.hbs index 9adb96c..844a892 100644 --- a/server/views/layout.hbs +++ b/server/views/layout.hbs @@ -55,8 +55,5 @@ {{{block "scripts"}}} - \ No newline at end of file diff --git a/server/views/login.hbs b/server/views/login.hbs index 54ddbdb..754db59 100644 --- a/server/views/login.hbs +++ b/server/views/login.hbs @@ -1,3 +1,3 @@ {{> header}} -{{> login_signup}} +{{> auth/form}} {{> footer}} diff --git a/server/views/logout.hbs b/server/views/logout.hbs new file mode 100644 index 0000000..26a4051 --- /dev/null +++ b/server/views/logout.hbs @@ -0,0 +1,7 @@ +{{> header}} +
+

+ Logged out. Redirecting to homepage... +

+
+{{> footer}} \ No newline at end of file diff --git a/server/views/partials/auth/form.hbs b/server/views/partials/auth/form.hbs new file mode 100644 index 0000000..beded6d --- /dev/null +++ b/server/views/partials/auth/form.hbs @@ -0,0 +1,54 @@ +
+ + + {{!-- TODO: Agree with terms --}} +
+ + +
+ Forgot your password? + {{#unless errors}} + {{#if error}} +

{{error}}

+ {{/if}} + {{/unless}} +
\ No newline at end of file diff --git a/server/views/partials/signup_verify_email.hbs b/server/views/partials/auth/verify.hbs similarity index 100% rename from server/views/partials/signup_verify_email.hbs rename to server/views/partials/auth/verify.hbs diff --git a/server/views/partials/login_welcome.hbs b/server/views/partials/auth/welcome.hbs similarity index 100% rename from server/views/partials/login_welcome.hbs rename to server/views/partials/auth/welcome.hbs diff --git a/server/views/partials/browser_extensions.hbs b/server/views/partials/browser_extensions.hbs new file mode 100644 index 0000000..28702bb --- /dev/null +++ b/server/views/partials/browser_extensions.hbs @@ -0,0 +1,13 @@ +
+

Browser extentions.

+ +
\ No newline at end of file diff --git a/server/views/partials/features.hbs b/server/views/partials/features.hbs new file mode 100644 index 0000000..d5e5e6c --- /dev/null +++ b/server/views/partials/features.hbs @@ -0,0 +1,33 @@ +
+

Kutting edge features.

+ +
\ No newline at end of file diff --git a/server/views/partials/header.hbs b/server/views/partials/header.hbs index 95575a5..6e2de86 100644 --- a/server/views/partials/header.hbs +++ b/server/views/partials/header.hbs @@ -1,4 +1,4 @@ -
+
\ No newline at end of file diff --git a/server/views/partials/icons/chart.hbs b/server/views/partials/icons/chart.hbs new file mode 100644 index 0000000..65f3c54 --- /dev/null +++ b/server/views/partials/icons/chart.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/views/partials/icons/pencil.hbs b/server/views/partials/icons/pencil.hbs new file mode 100644 index 0000000..bef7f58 --- /dev/null +++ b/server/views/partials/icons/pencil.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/views/partials/icons/reload.hbs b/server/views/partials/icons/reload.hbs new file mode 100644 index 0000000..604f0c1 --- /dev/null +++ b/server/views/partials/icons/reload.hbs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/server/views/partials/icons/spinner.hbs b/server/views/partials/icons/spinner.hbs new file mode 100644 index 0000000..79213a0 --- /dev/null +++ b/server/views/partials/icons/spinner.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/views/partials/icons/trash.hbs b/server/views/partials/icons/trash.hbs new file mode 100644 index 0000000..8a0dfe8 --- /dev/null +++ b/server/views/partials/icons/trash.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/views/partials/introduction.hbs b/server/views/partials/introduction.hbs new file mode 100644 index 0000000..e1fbe28 --- /dev/null +++ b/server/views/partials/introduction.hbs @@ -0,0 +1,7 @@ +
+
+

Manage links, set custom domains and view stats.

+ Log in / Sign up +
+ callout image +
\ No newline at end of file diff --git a/server/views/partials/links/actions.hbs b/server/views/partials/links/actions.hbs new file mode 100644 index 0000000..62f9f68 --- /dev/null +++ b/server/views/partials/links/actions.hbs @@ -0,0 +1,36 @@ + + + + + \ No newline at end of file diff --git a/server/views/partials/links/dialog.hbs b/server/views/partials/links/dialog.hbs new file mode 100644 index 0000000..78b22b1 --- /dev/null +++ b/server/views/partials/links/dialog.hbs @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/server/views/partials/links/dialog_content/main.hbs b/server/views/partials/links/dialog_content/main.hbs new file mode 100644 index 0000000..e69de29 diff --git a/server/views/partials/links/dialog_delete.hbs b/server/views/partials/links/dialog_delete.hbs new file mode 100644 index 0000000..ff39bc5 --- /dev/null +++ b/server/views/partials/links/dialog_delete.hbs @@ -0,0 +1,28 @@ +
+

Delete link?

+

+ Are you sure do you want to delete the link "{{link}}"? +

+
+ + + +
+
+ {{#if error}} +

{{error}}

+ {{/if}} +
+
\ No newline at end of file diff --git a/server/views/partials/links/dialog_delete_success.hbs b/server/views/partials/links/dialog_delete_success.hbs new file mode 100644 index 0000000..ce8a412 --- /dev/null +++ b/server/views/partials/links/dialog_delete_success.hbs @@ -0,0 +1,12 @@ +
+
+ +
+

+ Your link "{{link}}" has been deleted. +

+
+ +
+
+ diff --git a/server/views/partials/links/dialog_message.hbs b/server/views/partials/links/dialog_message.hbs new file mode 100644 index 0000000..1cc9e06 --- /dev/null +++ b/server/views/partials/links/dialog_message.hbs @@ -0,0 +1,7 @@ +
+

{{message}}

+
+ +
+
+ diff --git a/server/views/partials/links/edit.hbs b/server/views/partials/links/edit.hbs new file mode 100644 index 0000000..ae606e0 --- /dev/null +++ b/server/views/partials/links/edit.hbs @@ -0,0 +1,112 @@ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ {{#if error}} + {{#unless errors}} +

{{error}}

+ {{/unless}} + {{else if success}} +

{{success}}

+ {{/if}} +
+ +
+ \ No newline at end of file diff --git a/server/views/partials/links/loading.hbs b/server/views/partials/links/loading.hbs new file mode 100644 index 0000000..74a37eb --- /dev/null +++ b/server/views/partials/links/loading.hbs @@ -0,0 +1,16 @@ +{{#unless links}} + {{#ifEquals links.length 0}} + + + No links. + + + {{else}} + + + + Loading links... + + + {{/ifEquals}} +{{/unless}} \ No newline at end of file diff --git a/server/views/partials/links/nav.hbs b/server/views/partials/links/nav.hbs new file mode 100644 index 0000000..0880cd8 --- /dev/null +++ b/server/views/partials/links/nav.hbs @@ -0,0 +1,16 @@ + +
+ + + +
+
+ + \ No newline at end of file diff --git a/server/views/partials/links/table.hbs b/server/views/partials/links/table.hbs new file mode 100644 index 0000000..d848b0f --- /dev/null +++ b/server/views/partials/links/table.hbs @@ -0,0 +1,27 @@ + \ No newline at end of file diff --git a/server/views/partials/links/tbody.hbs b/server/views/partials/links/tbody.hbs new file mode 100644 index 0000000..1c7bdf6 --- /dev/null +++ b/server/views/partials/links/tbody.hbs @@ -0,0 +1,6 @@ + + {{> links/loading}} + {{#each links}} + {{> links/tr}} + {{/each}} + \ No newline at end of file diff --git a/server/views/partials/links/tfoot.hbs b/server/views/partials/links/tfoot.hbs new file mode 100644 index 0000000..9356a68 --- /dev/null +++ b/server/views/partials/links/tfoot.hbs @@ -0,0 +1,5 @@ + + + {{> links/nav}} + + \ No newline at end of file diff --git a/server/views/partials/links/thead.hbs b/server/views/partials/links/thead.hbs new file mode 100644 index 0000000..77297cb --- /dev/null +++ b/server/views/partials/links/thead.hbs @@ -0,0 +1,22 @@ + + + + + + + + + + {{> links/nav}} + + + Original URL + Created at + Short link + Views + + + \ No newline at end of file diff --git a/server/views/partials/links/tr.hbs b/server/views/partials/links/tr.hbs new file mode 100644 index 0000000..972461c --- /dev/null +++ b/server/views/partials/links/tr.hbs @@ -0,0 +1,42 @@ + + + + {{target}} + + {{#if description}} +

+ {{description}} +

+ {{/if}} + + + {{relative_created_at}} + {{#if relative_expire_in}} +

+ Expires in {{relative_expire_in}} +

+ {{/if}} + + + {{!--
+ + +
--}} + {{link.link}} + + + {{visit_count}} + + {{> links/actions}} + + + + {{> icons/spinner}} + + \ No newline at end of file diff --git a/server/views/partials/login_signup.hbs b/server/views/partials/login_signup.hbs deleted file mode 100644 index 80a5c22..0000000 --- a/server/views/partials/login_signup.hbs +++ /dev/null @@ -1,50 +0,0 @@ -
- - - {{!-- TODO: Agree with terms --}} -
- - -
- Forgot your password? - {{#if error}} -

{{error}}

- {{/if}} -
\ No newline at end of file diff --git a/server/views/partials/shortener.hbs b/server/views/partials/shortener.hbs new file mode 100644 index 0000000..89a6c9b --- /dev/null +++ b/server/views/partials/shortener.hbs @@ -0,0 +1,137 @@ +
+
+ {{#if link}} +
+ + +
+

+ {{link}} +

+ {{/if}} + {{#unless link}} +

Kutt your links shorter.

+ {{/unless}} +
+
+
+ + + {{#if errors.target}}

{{errors.target}}

{{/if}} + {{#unless errors}} + {{#if error}} +

{{error}}

+ {{/if}} + {{/unless}} +
+ +
+
+ + + +
+
+ + +
+
+
+
\ No newline at end of file diff --git a/server/views/partials/shorturl.hbs b/server/views/partials/shorturl.hbs deleted file mode 100644 index 93f2058..0000000 --- a/server/views/partials/shorturl.hbs +++ /dev/null @@ -1,7 +0,0 @@ -
- - -
-

{{link}}

\ No newline at end of file diff --git a/static/.DS_Store b/static/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..52b1e04a5b01bd8315913534d307c0d86129cbfd GIT binary patch literal 8196 zcmeHML5tHs6n?YqZc`Sa2ZeeIcv6bntgWtiiLrusG>aZoYSYwiFv*lA-GZf%ix>Sv zibuhp;MKq4o6PJcou-0pS%c2N%$Ll2Z!+(j%uL<{0Ejhn4S*&9XqXu`_OU2POwRIJ z+7UI^5eeEuG;w_D;N53uvZk#n6a$I@#eiZ!F`yVI0|U5cv!rd<_qkM+iUGyIf5`w_ zA9TzNO@Rr=bm_n%4FM3VSe+)kZm7kZ|^gd;Zx zB7zSho=il9Lei&W`+BMl#N?<-#eia9odFiRS6~PcM9w@Pzx!Skx7$C+PNllLSJP_R zzV_Nap>sPP#Iwj6Okc9QV@iGJ?0Vol_eS%6O~37ie{@-fuJ)i;iKmbmLBE+0qwB_YYfox6@uO>)N#&2M?b0-}ymEKXM0%SOnmT zt``}@PpE}-sjq?40Pt)AM` z(5K-TTVfsFg||Q6^)GgVvPd& zbN12@?12XX3}6hS5)8so7YD&Oyx<_L_>>=p6@f%V6?~+^DFF%Fc@xk5k^HRT!Zw5{ z`a1cC=AGnK!6cJ3@)Pa&>CIbT4$}2)O)Hyho3#oioYamq2DtR8R7M6Wyi-jU|3BV* z{$Iwft3D|P6a!mgfK+>S&%(yeKC9(+S*)#NeuJ4Mrk8L`O|V!w2*CUEABK3Xqbjp8 j1tuJ^2GgY<1Z2M`FF(ZD(s#GC->UsTeUnu2Ck*@oK|1=> literal 0 HcmV?d00001 diff --git a/static/css/styles.css b/static/css/styles.css index 66dc5fc..1b27334 100644 --- a/static/css/styles.css +++ b/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.spinner { - display: block; -} +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 label#advanced input { + width: 1.1rem; + height: 1.1rem; + margin-bottom: 2px; +} + +#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 { diff --git a/static/images/icons/spinner.svg b/static/images/icons/spinner.svg new file mode 100644 index 0000000..79213a0 --- /dev/null +++ b/static/images/icons/spinner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/scripts/main.js b/static/scripts/main.js index dc762ad..3700134 100644 --- a/static/scripts/main.js +++ b/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(); + }); } \ No newline at end of file