more htmx less nextjs
This commit is contained in:
parent
8fe106c2d6
commit
980610e7a0
@ -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",
|
||||
|
@ -36,7 +36,7 @@ const options = {
|
||||
colorize: true
|
||||
},
|
||||
console: {
|
||||
level: "debug",
|
||||
level: "error",
|
||||
handleExceptions: true,
|
||||
json: false,
|
||||
format: combine(colorize(), rawFormat)
|
||||
|
@ -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 });
|
121
server/handlers/helpers.handler.js
Normal file
121
server/handlers/helpers.handler.js
Normal 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,
|
||||
}
|
@ -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,
|
||||
}
|
@ -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,
|
||||
}
|
21
server/handlers/locals.handler.js
Normal file
21
server/handlers/locals.handler.js
Normal 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,
|
||||
}
|
13
server/handlers/types.d.ts
vendored
13
server/handlers/types.d.ts
vendored
@ -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;
|
||||
};
|
||||
}
|
@ -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,
|
@ -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
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
95
server/renders/renders.handler.js
Normal file
95
server/renders/renders.handler.js
Normal 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,
|
||||
}
|
27
server/renders/renders.helper.js
Normal file
27
server/renders/renders.helper.js
Normal 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,
|
||||
}
|
@ -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;
|
||||
|
@ -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)
|
||||
);
|
||||
|
@ -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)
|
||||
);
|
||||
|
@ -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",
|
@ -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);
|
||||
|
||||
|
@ -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)
|
||||
);
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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}}
|
||||
|
@ -55,8 +55,5 @@
|
||||
{{{block "scripts"}}}
|
||||
<script src="/libs/htmx.min.js"></script>
|
||||
<script src="/scripts/main.js"></script>
|
||||
<script>
|
||||
htmx.logAll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -1,3 +1,3 @@
|
||||
{{> header}}
|
||||
{{> login_signup}}
|
||||
{{> auth/form}}
|
||||
{{> footer}}
|
||||
|
7
server/views/logout.hbs
Normal file
7
server/views/logout.hbs
Normal 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}}
|
54
server/views/partials/auth/form.hbs
Normal file
54
server/views/partials/auth/form.hbs
Normal 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>
|
13
server/views/partials/browser_extensions.hbs
Normal file
13
server/views/partials/browser_extensions.hbs
Normal 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>
|
33
server/views/partials/features.hbs
Normal file
33
server/views/partials/features.hbs
Normal 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>
|
@ -1,4 +1,4 @@
|
||||
<header hx-boost="true">
|
||||
<header>
|
||||
<div class="logo-wrapper">
|
||||
<a class="logo nav" href="/" title="Kutt">
|
||||
<img src="/images/logo.svg" alt="kutt" width="18" height="24" />
|
||||
@ -20,21 +20,25 @@
|
||||
</div>
|
||||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="button primary" href="/login" title="Log in or sign up">
|
||||
Log in / Sign up
|
||||
</a>
|
||||
</li>
|
||||
{{!-- <li>
|
||||
<a class="button primary" href="/settings" title="Settings">
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="nav" href="/logout" title="Log out">
|
||||
Log out
|
||||
</a>
|
||||
</li> --}}
|
||||
{{#unless user}}
|
||||
<li>
|
||||
<a class="button primary" href="/login" title="Log in or sign up">
|
||||
Log in / Sign up
|
||||
</a>
|
||||
</li>
|
||||
{{/unless}}
|
||||
{{#if user}}
|
||||
<li>
|
||||
<a class="button primary" href="/settings" title="Settings">
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="nav" href="/logout" title="Log out">
|
||||
Log out
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
1
server/views/partials/icons/chart.hbs
Normal file
1
server/views/partials/icons/chart.hbs
Normal 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 |
1
server/views/partials/icons/pencil.hbs
Normal file
1
server/views/partials/icons/pencil.hbs
Normal 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 |
2
server/views/partials/icons/reload.hbs
Normal file
2
server/views/partials/icons/reload.hbs
Normal 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 |
1
server/views/partials/icons/spinner.hbs
Normal file
1
server/views/partials/icons/spinner.hbs
Normal 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 |
1
server/views/partials/icons/trash.hbs
Normal file
1
server/views/partials/icons/trash.hbs
Normal 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 |
7
server/views/partials/introduction.hbs
Normal file
7
server/views/partials/introduction.hbs
Normal 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>
|
36
server/views/partials/links/actions.hbs
Normal file
36
server/views/partials/links/actions.hbs
Normal 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>
|
8
server/views/partials/links/dialog.hbs
Normal file
8
server/views/partials/links/dialog.hbs
Normal 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>
|
0
server/views/partials/links/dialog_content/main.hbs
Normal file
0
server/views/partials/links/dialog_content/main.hbs
Normal file
28
server/views/partials/links/dialog_delete.hbs
Normal file
28
server/views/partials/links/dialog_delete.hbs
Normal file
@ -0,0 +1,28 @@
|
||||
<div class="content">
|
||||
<h2>Delete link?</h2>
|
||||
<p>
|
||||
Are you sure do you want to delete the link "<span class="link-to-delete">{{link}}</span>"?
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button hx-on:click="closeDialog()">Cancel</button>
|
||||
<button
|
||||
class="danger confirm"
|
||||
hx-delete="/api/links/{id}"
|
||||
hx-ext="path-params"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
hx-target="closest .content"
|
||||
hx-swap="none"
|
||||
hx-indicator="closest .content"
|
||||
hx-select-oob="#dialog-error"
|
||||
>
|
||||
<svg class="with-text action" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M3 6h18m-2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m-6 5v6m4-6v6"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
|
||||
</div>
|
||||
<div id="dialog-error">
|
||||
{{#if error}}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
12
server/views/partials/links/dialog_delete_success.hbs
Normal file
12
server/views/partials/links/dialog_delete_success.hbs
Normal 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>
|
||||
|
7
server/views/partials/links/dialog_message.hbs
Normal file
7
server/views/partials/links/dialog_message.hbs
Normal file
@ -0,0 +1,7 @@
|
||||
<div class="content">
|
||||
<p>{{message}}</p>
|
||||
<div class="buttons">
|
||||
<button hx-on:click="closeDialog()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
112
server/views/partials/links/edit.hbs
Normal file
112
server/views/partials/links/edit.hbs
Normal 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>
|
16
server/views/partials/links/loading.hbs
Normal file
16
server/views/partials/links/loading.hbs
Normal 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}}
|
16
server/views/partials/links/nav.hbs
Normal file
16
server/views/partials/links/nav.hbs
Normal 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>
|
27
server/views/partials/links/table.hbs
Normal file
27
server/views/partials/links/table.hbs
Normal 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>
|
6
server/views/partials/links/tbody.hbs
Normal file
6
server/views/partials/links/tbody.hbs
Normal file
@ -0,0 +1,6 @@
|
||||
<tbody>
|
||||
{{> links/loading}}
|
||||
{{#each links}}
|
||||
{{> links/tr}}
|
||||
{{/each}}
|
||||
</tbody>
|
5
server/views/partials/links/tfoot.hbs
Normal file
5
server/views/partials/links/tfoot.hbs
Normal file
@ -0,0 +1,5 @@
|
||||
<tfoot>
|
||||
<tr class="links-controls">
|
||||
{{> links/nav}}
|
||||
</tr>
|
||||
</tfoot>
|
22
server/views/partials/links/thead.hbs
Normal file
22
server/views/partials/links/thead.hbs
Normal 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>
|
42
server/views/partials/links/tr.hbs
Normal file
42
server/views/partials/links/tr.hbs
Normal 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>
|
@ -1,50 +0,0 @@
|
||||
<form id="login-signup" hx-post="/api/auth/login" hx-swap="outerHTML">
|
||||
<label>
|
||||
Email address:
|
||||
<input
|
||||
name="email"
|
||||
id="email"
|
||||
type="email"
|
||||
autofocus="true"
|
||||
placeholder="Email address..."
|
||||
hx-preserve="true"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password:
|
||||
<input
|
||||
name="password"
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Password..."
|
||||
hx-preserve="true"
|
||||
/>
|
||||
</label>
|
||||
{{!-- TODO: Agree with terms --}}
|
||||
<div class="buttons-wrapper">
|
||||
<button type="submit" class="primary login">
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4m-5-4 5-5-5-5m3.8 5H3"/></svg>
|
||||
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
class="secondary signup"
|
||||
hx-post="/api/auth/signup"
|
||||
hx-target="#login-signup"
|
||||
hx-trigger="click"
|
||||
hx-indicator="#login-signup"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="closest form"
|
||||
hx-on:htmx:before-request="htmx.addClass('#login-signup', 'signup')"
|
||||
hx-on:htmx:after-request="htmx.removeClass('#login-signup', 'signup')"
|
||||
>
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6m3-3h-6"/></svg>
|
||||
<svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
<a class="forgot-password" href="/forgot-password" title="Reset password">Forgot your password?</a>
|
||||
{{#if error}}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/if}}
|
||||
</form>
|
137
server/views/partials/shortener.hbs
Normal file
137
server/views/partials/shortener.hbs
Normal 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>
|
@ -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
BIN
static/.DS_Store
vendored
Normal file
Binary file not shown.
@ -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 {
|
||||
|
1
static/images/icons/spinner.svg
Normal file
1
static/images/icons/spinner.svg
Normal 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 |
@ -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();
|
||||
});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user