more htmx less nextjs

This commit is contained in:
Pouria Ezzati 2024-08-21 21:22:59 +03:30
parent 8fe106c2d6
commit 980610e7a0
No known key found for this signature in database
73 changed files with 2101 additions and 642 deletions

View File

@ -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",

View File

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

View File

@ -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 });

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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;
};
}

View File

@ -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,

View File

@ -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
};

View File

@ -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,

View File

@ -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) {

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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;

View File

@ -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)
);

View File

@ -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)
);

View File

@ -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",

View File

@ -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);

View File

@ -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)
);

View File

@ -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);

View File

@ -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,

View File

@ -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}}

View File

@ -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>

View File

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

7
server/views/logout.hbs Normal file
View File

@ -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}}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

After

Width:  |  Height:  |  Size: 159 B

View File

@ -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>

After

Width:  |  Height:  |  Size: 127 B

View File

@ -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>

After

Width:  |  Height:  |  Size: 205 B

View File

@ -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>

After

Width:  |  Height:  |  Size: 213 B

View File

@ -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>

After

Width:  |  Height:  |  Size: 210 B

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

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

View File

@ -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>

View File

@ -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}}

View File

@ -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>

View File

@ -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>

View File

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

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 vendored Normal file

Binary file not shown.

View File

@ -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 {

View File

@ -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>

After

Width:  |  Height:  |  Size: 213 B

View File

@ -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();
});
}