add admin page
This commit is contained in:
parent
3ed2a8de3e
commit
8a73c5ec4c
8
server/consts.js
Normal file
8
server/consts.js
Normal file
@ -0,0 +1,8 @@
|
||||
const ROLES = {
|
||||
USER: "USER",
|
||||
ADMIN: "ADMIN"
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
ROLES,
|
||||
}
|
@ -42,19 +42,20 @@ function authenticate(type, error, isStrict, redirect) {
|
||||
throw new CustomError(error, 401);
|
||||
}
|
||||
|
||||
if (user && user.banned) {
|
||||
throw new CustomError("You're banned from using this website.", 403);
|
||||
}
|
||||
|
||||
if (user && isStrict && !user.verified) {
|
||||
throw new CustomError("Your email address is not verified. " +
|
||||
"Sign up to get the verification link again.", 400);
|
||||
}
|
||||
|
||||
if (user && user.banned) {
|
||||
throw new CustomError("You're banned from using this website.", 403);
|
||||
}
|
||||
if (user) {
|
||||
res.locals.isAdmin = utils.isAdmin(user.email);
|
||||
res.locals.isAdmin = utils.isAdmin(user);
|
||||
req.user = {
|
||||
...user,
|
||||
admin: utils.isAdmin(user.email)
|
||||
admin: utils.isAdmin(user)
|
||||
};
|
||||
|
||||
// renew token if it's been at least one day since the token has been created
|
||||
|
@ -3,6 +3,7 @@ const { Handler } = require("express");
|
||||
const { CustomError, sanitize } = require("../utils");
|
||||
const query = require("../queries");
|
||||
const redis = require("../redis");
|
||||
const utils = require("../utils");
|
||||
const env = require("../env");
|
||||
|
||||
async function add(req, res) {
|
||||
@ -26,6 +27,27 @@ async function add(req, res) {
|
||||
return res.status(200).send(sanitize.domain(domain));
|
||||
};
|
||||
|
||||
async function addAdmin(req, res) {
|
||||
const { address, banned, homepage } = req.body;
|
||||
|
||||
const domain = await query.domain.add({
|
||||
address,
|
||||
homepage,
|
||||
banned,
|
||||
...(banned && { banned_by_id: req.user.id })
|
||||
});
|
||||
|
||||
if (req.isHTML) {
|
||||
res.setHeader("HX-Trigger", "reloadMainTable");
|
||||
res.render("partials/admin/dialog/add_domain_success", {
|
||||
address: domain.address,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return res.status(200).send({ message: "The domain has been added successfully." });
|
||||
};
|
||||
|
||||
async function remove(req, res) {
|
||||
const domain = await query.domain.find({
|
||||
uuid: req.params.id,
|
||||
@ -33,7 +55,7 @@ async function remove(req, res) {
|
||||
});
|
||||
|
||||
if (!domain) {
|
||||
throw new CustomError("Could not delete the domain.", 500);
|
||||
throw new CustomError("Could not delete the domain.", 400);
|
||||
}
|
||||
|
||||
const [updatedDomain] = await query.domain.update(
|
||||
@ -62,7 +84,130 @@ async function remove(req, res) {
|
||||
return res.status(200).send({ message: "Domain deleted successfully" });
|
||||
};
|
||||
|
||||
async function removeAdmin(req, res) {
|
||||
const id = req.params.id;
|
||||
const links = req.query.links
|
||||
|
||||
const domain = await query.domain.find({ id });
|
||||
|
||||
if (!domain) {
|
||||
throw new CustomError("Could not find the domain.", 400);
|
||||
}
|
||||
|
||||
if (links) {
|
||||
await query.link.batchRemove({ domain_id: id });
|
||||
}
|
||||
|
||||
await query.domain.remove(domain);
|
||||
|
||||
if (req.isHTML) {
|
||||
res.setHeader("HX-Reswap", "outerHTML");
|
||||
res.setHeader("HX-Trigger", "reloadMainTable");
|
||||
res.render("partials/admin/dialog/delete_domain_success", {
|
||||
address: domain.address,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return res.status(200).send({ message: "Domain deleted successfully" });
|
||||
}
|
||||
|
||||
async function getAdmin(req, res) {
|
||||
const { limit, skip } = req.context;
|
||||
const search = req.query.search;
|
||||
const user = req.query.user;
|
||||
const banned = utils.parseBooleanQuery(req.query.banned);
|
||||
const owner = utils.parseBooleanQuery(req.query.owner);
|
||||
const links = utils.parseBooleanQuery(req.query.links);
|
||||
|
||||
const match = {
|
||||
...(banned !== undefined && { banned }),
|
||||
...(owner !== undefined && { user_id: [owner ? "is not" : "is", null] }),
|
||||
};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
query.domain.getAdmin(match, { limit, search, user, links, skip }),
|
||||
query.domain.totalAdmin(match, { search, user, links })
|
||||
]);
|
||||
|
||||
const domains = data.map(utils.sanitize.domain_admin);
|
||||
|
||||
if (req.isHTML) {
|
||||
res.render("partials/admin/domains/table", {
|
||||
total,
|
||||
total_formatted: total.toLocaleString("en-US"),
|
||||
limit,
|
||||
skip,
|
||||
table_domains: domains,
|
||||
})
|
||||
return;
|
||||
}
|
||||
|
||||
return res.send({
|
||||
total,
|
||||
limit,
|
||||
skip,
|
||||
data: domains,
|
||||
});
|
||||
}
|
||||
|
||||
async function ban(req, res) {
|
||||
const { id } = req.params;
|
||||
|
||||
const update = {
|
||||
banned_by_id: req.user.id,
|
||||
banned: true
|
||||
};
|
||||
|
||||
// 1. check if domain exists
|
||||
const domain = await query.domain.find({ id });
|
||||
|
||||
if (!domain) {
|
||||
throw new CustomError("No domain has been found.", 400);
|
||||
}
|
||||
|
||||
if (domain.banned) {
|
||||
throw new CustomError("Domain has been banned already.", 400);
|
||||
}
|
||||
|
||||
const tasks = [];
|
||||
|
||||
// 2. ban domain
|
||||
tasks.push(query.domain.update({ id }, update));
|
||||
|
||||
// 3. ban user
|
||||
if (req.body.user && domain.user_id) {
|
||||
tasks.push(query.user.update({ id: domain.user_id }, update));
|
||||
}
|
||||
|
||||
// 4. ban links
|
||||
if (req.body.links) {
|
||||
tasks.push(query.link.update({ domain_id: id }, update));
|
||||
}
|
||||
|
||||
// 5. wait for all tasks to finish
|
||||
await Promise.all(tasks).catch((err) => {
|
||||
throw new CustomError("Couldn't ban entries.");
|
||||
});
|
||||
|
||||
// 6. send response
|
||||
if (req.isHTML) {
|
||||
res.setHeader("HX-Reswap", "outerHTML");
|
||||
res.setHeader("HX-Trigger", "reloadMainTable");
|
||||
res.render("partials/admin/dialog/ban_domain_success", {
|
||||
address: domain.address,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return res.status(200).send({ message: "Banned domain successfully." });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
add,
|
||||
addAdmin,
|
||||
ban,
|
||||
getAdmin,
|
||||
remove,
|
||||
removeAdmin,
|
||||
}
|
@ -80,7 +80,6 @@ function parseQuery(req, res, next) {
|
||||
req.context = {
|
||||
limit: limit > 50 ? 50 : limit,
|
||||
skip: parseInt(req.query.skip) || 0,
|
||||
all: admin ? req.query.all === "true" || req.query.all === "on" : false
|
||||
};
|
||||
|
||||
next();
|
||||
|
@ -17,12 +17,12 @@ const CustomError = utils.CustomError;
|
||||
const dnsLookup = promisify(dns.lookup);
|
||||
|
||||
async function get(req, res) {
|
||||
const { limit, skip, all } = req.context;
|
||||
const { limit, skip } = req.context;
|
||||
const search = req.query.search;
|
||||
const userId = req.user.id;
|
||||
|
||||
const match = {
|
||||
...(!all && { user_id: userId })
|
||||
user_id: userId
|
||||
};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
@ -50,6 +50,54 @@ async function get(req, res) {
|
||||
});
|
||||
};
|
||||
|
||||
async function getAdmin(req, res) {
|
||||
const { limit, skip } = req.context;
|
||||
const search = req.query.search;
|
||||
const user = req.query.user;
|
||||
let domain = req.query.domain;
|
||||
const banned = utils.parseBooleanQuery(req.query.banned);
|
||||
const anonymous = utils.parseBooleanQuery(req.query.anonymous);
|
||||
const has_domain = utils.parseBooleanQuery(req.query.has_domain);
|
||||
|
||||
const match = {
|
||||
...(banned !== undefined && { banned }),
|
||||
...(anonymous !== undefined && { user_id: [anonymous ? "is" : "is not", null] }),
|
||||
...(has_domain !== undefined && { domain_id: [has_domain ? "is not" : "is", null] }),
|
||||
};
|
||||
|
||||
// if domain is equal to the defualt domain,
|
||||
// it means admins is looking for links with the defualt domain (no custom user domain)
|
||||
if (domain === env.DEFAULT_DOMAIN) {
|
||||
domain = undefined;
|
||||
match.domain_id = null;
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
query.link.getAdmin(match, { limit, search, user, domain, skip }),
|
||||
query.link.totalAdmin(match, { search, user, domain })
|
||||
]);
|
||||
|
||||
const links = data.map(utils.sanitize.link_admin);
|
||||
|
||||
if (req.isHTML) {
|
||||
res.render("partials/admin/links/table", {
|
||||
total,
|
||||
total_formatted: total.toLocaleString("en-US"),
|
||||
limit,
|
||||
skip,
|
||||
links,
|
||||
})
|
||||
return;
|
||||
}
|
||||
|
||||
return res.send({
|
||||
total,
|
||||
limit,
|
||||
skip,
|
||||
data: links,
|
||||
});
|
||||
};
|
||||
|
||||
async function create(req, res) {
|
||||
const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body;
|
||||
const domain_id = fetched_domain ? fetched_domain.id : null;
|
||||
@ -108,7 +156,7 @@ async function create(req, res) {
|
||||
link.domain = fetched_domain?.address;
|
||||
|
||||
if (req.isHTML) {
|
||||
res.setHeader("HX-Trigger", "reloadLinks");
|
||||
res.setHeader("HX-Trigger", "reloadMainTable");
|
||||
const shortURL = utils.getShortURL(link.address, link.domain);
|
||||
return res.render("partials/shortener", {
|
||||
link: shortURL.link,
|
||||
@ -216,6 +264,101 @@ async function edit(req, res) {
|
||||
return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
|
||||
};
|
||||
|
||||
async function editAdmin(req, res) {
|
||||
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.");
|
||||
}
|
||||
|
||||
let isChanged = false;
|
||||
[
|
||||
[req.body.address, "address"],
|
||||
[req.body.target, "target"],
|
||||
[req.body.description, "description"],
|
||||
[req.body.expire_in, "expire_in"],
|
||||
[req.body.password, "password"]
|
||||
].forEach(([value, name]) => {
|
||||
if (!value) {
|
||||
if (name === "password" && link.password)
|
||||
req.body.password = null;
|
||||
else {
|
||||
delete req.body[name];
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (value === link[name] && name !== "password") {
|
||||
delete req.body[name];
|
||||
return;
|
||||
}
|
||||
if (name === "expire_in" && link.expire_in)
|
||||
if (Math.abs(differenceInSeconds(utils.parseDatetime(value), utils.parseDatetime(link.expire_in))) < 60)
|
||||
return;
|
||||
if (name === "password")
|
||||
if (value && value.replace(/•/ig, "").length === 0) {
|
||||
delete req.body.password;
|
||||
return;
|
||||
}
|
||||
isChanged = true;
|
||||
});
|
||||
|
||||
if (!isChanged) {
|
||||
throw new CustomError("Should at least update one field.");
|
||||
}
|
||||
|
||||
const { address, target, description, expire_in, password } = req.body;
|
||||
|
||||
const targetDomain = target && utils.removeWww(URL.parse(target).hostname);
|
||||
const domain_id = link.domain_id || null;
|
||||
|
||||
const tasks = await Promise.all([
|
||||
validators.cooldown(req.user),
|
||||
target && validators.malware(req.user, target),
|
||||
address &&
|
||||
query.link.find({
|
||||
address,
|
||||
domain_id
|
||||
}),
|
||||
target && validators.bannedDomain(targetDomain),
|
||||
target && validators.bannedHost(targetDomain)
|
||||
]);
|
||||
|
||||
// Check if custom link already exists
|
||||
if (tasks[2]) {
|
||||
const error = "Custom URL is already in use.";
|
||||
res.locals.errors = { address: error };
|
||||
throw new CustomError("Custom URL is already in use.");
|
||||
}
|
||||
|
||||
// Update link
|
||||
const [updatedLink] = await query.link.update(
|
||||
{
|
||||
id: link.id
|
||||
},
|
||||
{
|
||||
...(address && { address }),
|
||||
...(description && { description }),
|
||||
...(target && { target }),
|
||||
...(expire_in && { expire_in }),
|
||||
...((password || password === null) && { password })
|
||||
}
|
||||
);
|
||||
|
||||
if (req.isHTML) {
|
||||
res.render("partials/admin/links/edit", {
|
||||
swap_oob: true,
|
||||
success: "Link has been updated.",
|
||||
...utils.sanitize.linkAdmin({ ...link, ...updatedLink }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
|
||||
};
|
||||
|
||||
async function remove(req, res) {
|
||||
const { error, isRemoved, link } = await query.link.remove({
|
||||
uuid: req.params.id,
|
||||
@ -229,7 +372,7 @@ async function remove(req, res) {
|
||||
|
||||
if (req.isHTML) {
|
||||
res.setHeader("HX-Reswap", "outerHTML");
|
||||
res.setHeader("HX-Trigger", "reloadLinks");
|
||||
res.setHeader("HX-Trigger", "reloadMainTable");
|
||||
res.render("partials/links/dialog/delete_success", {
|
||||
link: utils.getShortURL(link.address, link.domain).link,
|
||||
});
|
||||
@ -266,7 +409,7 @@ async function ban(req, res) {
|
||||
banned: true
|
||||
};
|
||||
|
||||
// 1. Check if link exists
|
||||
// 1. check if link exists
|
||||
const link = await query.link.find({ uuid: id });
|
||||
|
||||
if (!link) {
|
||||
@ -279,17 +422,17 @@ async function ban(req, res) {
|
||||
|
||||
const tasks = [];
|
||||
|
||||
// 2. Ban link
|
||||
// 2. ban link
|
||||
tasks.push(query.link.update({ uuid: id }, update));
|
||||
|
||||
const domain = utils.removeWww(URL.parse(link.target).hostname);
|
||||
|
||||
// 3. Ban target's domain
|
||||
// 3. ban target's domain
|
||||
if (req.body.domain) {
|
||||
tasks.push(query.domain.add({ ...update, address: domain }));
|
||||
}
|
||||
|
||||
// 4. Ban target's host
|
||||
// 4. ban target's host
|
||||
if (req.body.host) {
|
||||
const dnsRes = await dnsLookup(domain).catch(() => {
|
||||
throw new CustomError("Couldn't fetch DNS info.");
|
||||
@ -298,25 +441,25 @@ async function ban(req, res) {
|
||||
tasks.push(query.host.add({ ...update, address: host }));
|
||||
}
|
||||
|
||||
// 5. Ban link owner
|
||||
// 5. ban link owner
|
||||
if (req.body.user && link.user_id) {
|
||||
tasks.push(query.user.update({ id: link.user_id }, update));
|
||||
}
|
||||
|
||||
// 6. Ban all of owner's links
|
||||
// 6. ban all of owner's links
|
||||
if (req.body.userLinks && link.user_id) {
|
||||
tasks.push(query.link.update({ user_id: link.user_id }, update));
|
||||
}
|
||||
|
||||
// 7. Wait for all tasks to finish
|
||||
// 7. wait for all tasks to finish
|
||||
await Promise.all(tasks).catch((err) => {
|
||||
throw new CustomError("Couldn't ban entries.");
|
||||
});
|
||||
|
||||
// 8. Send response
|
||||
// 8. send response
|
||||
if (req.isHTML) {
|
||||
res.setHeader("HX-Reswap", "outerHTML");
|
||||
res.setHeader("HX-Trigger", "reloadLinks");
|
||||
res.setHeader("HX-Trigger", "reloadMainTable");
|
||||
res.render("partials/links/dialog/ban_success", {
|
||||
link: utils.getShortURL(link.address, link.domain).link,
|
||||
});
|
||||
@ -503,7 +646,9 @@ module.exports = {
|
||||
ban,
|
||||
create,
|
||||
edit,
|
||||
editAdmin,
|
||||
get,
|
||||
getAdmin,
|
||||
remove,
|
||||
report,
|
||||
stats,
|
||||
|
@ -53,7 +53,22 @@ function protected(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
function adminTable(req, res, next) {
|
||||
res.locals.query = {
|
||||
anonymous: req.query.anonymous,
|
||||
domain: req.query.domain,
|
||||
domains: req.query.domains,
|
||||
links: req.query.links,
|
||||
role: req.query.role,
|
||||
search: req.query.search,
|
||||
user: req.query.user,
|
||||
verified: req.query.verified,
|
||||
};
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
adminTable,
|
||||
config,
|
||||
createLink,
|
||||
editLink,
|
||||
|
@ -37,6 +37,12 @@ function settings(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
function admin(req, res) {
|
||||
res.render("admin", {
|
||||
title: "Admin"
|
||||
});
|
||||
}
|
||||
|
||||
function stats(req, res) {
|
||||
res.render("stats", {
|
||||
title: "Stats"
|
||||
@ -119,6 +125,48 @@ async function confirmLinkBan(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmUserDelete(req, res) {
|
||||
const user = await query.user.find({ id: req.query.id });
|
||||
if (!user) {
|
||||
return res.render("partials/admin/dialog/message", {
|
||||
layout: false,
|
||||
message: "Could not find the user."
|
||||
});
|
||||
}
|
||||
res.render("partials/admin/dialog/delete_user", {
|
||||
layout: false,
|
||||
email: user.email,
|
||||
id: user.id
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmUserBan(req, res) {
|
||||
const user = await query.user.find({ id: req.query.id });
|
||||
if (!user) {
|
||||
return res.render("partials/admin/dialog/message", {
|
||||
layout: false,
|
||||
message: "Could not find the user."
|
||||
});
|
||||
}
|
||||
res.render("partials/admin/dialog/ban_user", {
|
||||
layout: false,
|
||||
email: user.email,
|
||||
id: user.id
|
||||
});
|
||||
}
|
||||
|
||||
async function createUser(req, res) {
|
||||
res.render("partials/admin/dialog/create_user", {
|
||||
layout: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function addDomainAdmin(req, res) {
|
||||
res.render("partials/admin/dialog/add_domain", {
|
||||
layout: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function addDomainForm(req, res) {
|
||||
res.render("partials/settings/domain/add_form");
|
||||
}
|
||||
@ -129,13 +177,44 @@ async function confirmDomainDelete(req, res) {
|
||||
user_id: req.user.id
|
||||
});
|
||||
if (!domain) {
|
||||
throw new utils.CustomError("Could not find the link", 400);
|
||||
throw new utils.CustomError("Could not find the domain.", 400);
|
||||
}
|
||||
res.render("partials/settings/domain/delete", {
|
||||
...utils.sanitize.domain(domain)
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmDomainBan(req, res) {
|
||||
const domain = await query.domain.find({
|
||||
id: req.query.id
|
||||
});
|
||||
if (!domain) {
|
||||
throw new utils.CustomError("Could not find the domain.", 400);
|
||||
}
|
||||
const hasUser = !!domain.user_id;
|
||||
const hasLink = await query.link.find({ domain_id: domain.id });
|
||||
res.render("partials/admin/dialog/ban_domain", {
|
||||
id: domain.id,
|
||||
address: domain.address,
|
||||
hasUser,
|
||||
hasLink,
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmDomainDeleteAdmin(req, res) {
|
||||
const domain = await query.domain.find({
|
||||
id: req.query.id
|
||||
});
|
||||
if (!domain) {
|
||||
throw new utils.CustomError("Could not find the domain.", 400);
|
||||
}
|
||||
const hasLink = await query.link.find({ domain_id: domain.id });
|
||||
res.render("partials/admin/dialog/delete_domain", {
|
||||
id: domain.id,
|
||||
address: domain.address,
|
||||
hasLink,
|
||||
});
|
||||
}
|
||||
|
||||
async function getReportEmail(req, res) {
|
||||
if (!env.REPORT_EMAIL) {
|
||||
@ -166,16 +245,33 @@ async function linkEdit(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
async function linkEditAdmin(req, res) {
|
||||
const link = await query.link.find({
|
||||
uuid: req.params.id,
|
||||
});
|
||||
res.render("partials/admin/links/edit", {
|
||||
...(link && utils.sanitize.link(link)),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addDomainAdmin,
|
||||
addDomainForm,
|
||||
admin,
|
||||
banned,
|
||||
confirmDomainBan,
|
||||
confirmDomainDelete,
|
||||
confirmDomainDeleteAdmin,
|
||||
confirmLinkBan,
|
||||
confirmLinkDelete,
|
||||
confirmUserBan,
|
||||
confirmUserDelete,
|
||||
createUser,
|
||||
getReportEmail,
|
||||
getSupportEmail,
|
||||
homepage,
|
||||
linkEdit,
|
||||
linkEditAdmin,
|
||||
login,
|
||||
logout,
|
||||
notFound,
|
||||
|
@ -1,5 +1,8 @@
|
||||
const bcrypt = require("bcryptjs");
|
||||
|
||||
const query = require("../queries");
|
||||
const utils = require("../utils");
|
||||
const mail = require("../mail");
|
||||
const env = require("../env");
|
||||
|
||||
async function get(req, res) {
|
||||
@ -29,7 +32,154 @@ async function remove(req, res) {
|
||||
return res.status(200).send("OK");
|
||||
};
|
||||
|
||||
async function removeByAdmin(req, res) {
|
||||
const user = await query.user.find({ id: req.params.id });
|
||||
|
||||
if (!user) {
|
||||
const message = "Could not find the user.";
|
||||
if (req.isHTML) {
|
||||
return res.render("partials/admin/dialog/message", {
|
||||
layout: false,
|
||||
message
|
||||
});
|
||||
} else {
|
||||
return res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
await query.user.remove(user);
|
||||
|
||||
if (req.isHTML) {
|
||||
res.setHeader("HX-Reswap", "outerHTML");
|
||||
res.setHeader("HX-Trigger", "reloadMainTable");
|
||||
res.render("partials/admin/dialog/delete_user_success", {
|
||||
email: user.email,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return res.status(200).send({ message: "User has been deleted successfully." });
|
||||
};
|
||||
|
||||
async function getAdmin(req, res) {
|
||||
const { limit, skip, all } = req.context;
|
||||
const { role, search } = req.query;
|
||||
const userId = req.user.id;
|
||||
const verified = utils.parseBooleanQuery(req.query.verified);
|
||||
const banned = utils.parseBooleanQuery(req.query.banned);
|
||||
const domains = utils.parseBooleanQuery(req.query.domains);
|
||||
const links = utils.parseBooleanQuery(req.query.links);
|
||||
|
||||
const match = {
|
||||
...(role && { role }),
|
||||
...(verified !== undefined && { verified }),
|
||||
...(banned !== undefined && { banned }),
|
||||
};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
query.user.getAdmin(match, { limit, search, domains, links, skip }),
|
||||
query.user.totalAdmin(match, { search, domains, links })
|
||||
]);
|
||||
|
||||
const users = data.map(utils.sanitize.user_admin);
|
||||
|
||||
if (req.isHTML) {
|
||||
res.render("partials/admin/users/table", {
|
||||
total,
|
||||
total_formatted: total.toLocaleString("en-US"),
|
||||
limit,
|
||||
skip,
|
||||
users,
|
||||
})
|
||||
return;
|
||||
}
|
||||
|
||||
return res.send({
|
||||
total,
|
||||
limit,
|
||||
skip,
|
||||
data: users,
|
||||
});
|
||||
};
|
||||
|
||||
async function ban(req, res) {
|
||||
const { id } = req.params;
|
||||
|
||||
const update = {
|
||||
banned_by_id: req.user.id,
|
||||
banned: true
|
||||
};
|
||||
|
||||
// 1. check if user exists
|
||||
const user = await query.user.find({ id });
|
||||
|
||||
if (!user) {
|
||||
throw new CustomError("No user has been found.", 400);
|
||||
}
|
||||
|
||||
if (user.banned) {
|
||||
throw new CustomError("User has been banned already.", 400);
|
||||
}
|
||||
|
||||
const tasks = [];
|
||||
|
||||
// 2. ban user
|
||||
tasks.push(query.user.update({ id }, update));
|
||||
|
||||
// 3. ban user links
|
||||
if (req.body.links) {
|
||||
tasks.push(query.link.update({ user_id: id }, update));
|
||||
}
|
||||
|
||||
// 4. ban user domains
|
||||
if (req.body.domains) {
|
||||
tasks.push(query.domain.update({ user_id: id }, update));
|
||||
}
|
||||
|
||||
// 5. wait for all tasks to finish
|
||||
await Promise.all(tasks).catch((err) => {
|
||||
throw new CustomError("Couldn't ban entries.");
|
||||
});
|
||||
|
||||
// 6. send response
|
||||
if (req.isHTML) {
|
||||
res.setHeader("HX-Reswap", "outerHTML");
|
||||
res.setHeader("HX-Trigger", "reloadMainTable");
|
||||
res.render("partials/admin/dialog/ban_user_success", {
|
||||
email: user.email,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return res.status(200).send({ message: "Banned user successfully." });
|
||||
}
|
||||
|
||||
async function create(req, res) {
|
||||
const salt = await bcrypt.genSalt(12);
|
||||
req.body.password = await bcrypt.hash(req.body.password, salt);
|
||||
|
||||
const user = await query.user.create(req.body);
|
||||
|
||||
if (req.body.verification_email && !user.banned && !user.verified) {
|
||||
await mail.verification(user);
|
||||
}
|
||||
|
||||
if (req.isHTML) {
|
||||
res.setHeader("HX-Trigger", "reloadMainTable");
|
||||
res.render("partials/admin/dialog/create_user_success", {
|
||||
email: user.email,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return res.status(201).send({ message: "The user has been created successfully." });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ban,
|
||||
create,
|
||||
get,
|
||||
getAdmin,
|
||||
remove,
|
||||
removeByAdmin,
|
||||
}
|
@ -1,11 +1,12 @@
|
||||
const { isAfter, subDays, subHours, addMilliseconds, differenceInHours } = require("date-fns");
|
||||
const { body, param } = require("express-validator");
|
||||
const { body, param, query: queryValidator } = require("express-validator");
|
||||
const promisify = require("util").promisify;
|
||||
const bcrypt = require("bcryptjs");
|
||||
const dns = require("dns");
|
||||
const URL = require("url");
|
||||
const ms = require("ms");
|
||||
|
||||
const { ROLES } = require("../consts");
|
||||
const query = require("../queries");
|
||||
const utils = require("../utils");
|
||||
const knex = require("../knex");
|
||||
@ -187,6 +188,36 @@ const addDomain = [
|
||||
.withMessage("Homepage is not valid.")
|
||||
];
|
||||
|
||||
const addDomainAdmin = [
|
||||
body("address", "Domain is not valid.")
|
||||
.exists({ checkFalsy: true, checkNull: true })
|
||||
.isLength({ min: 3, max: 64 })
|
||||
.withMessage("Domain length must be between 3 and 64.")
|
||||
.trim()
|
||||
.customSanitizer(utils.addProtocol)
|
||||
.custom(value => utils.urlRegex.test(value))
|
||||
.customSanitizer(value => {
|
||||
const parsed = URL.parse(value);
|
||||
return utils.removeWww(parsed.hostname || parsed.href);
|
||||
})
|
||||
.custom(value => value !== env.DEFAULT_DOMAIN)
|
||||
.withMessage("You can't add the default domain.")
|
||||
.custom(async value => {
|
||||
const domain = await query.domain.find({ address: value });
|
||||
if (domain) return Promise.reject();
|
||||
})
|
||||
.withMessage("Domain already exists."),
|
||||
body("homepage")
|
||||
.optional({ checkFalsy: true, nullable: true })
|
||||
.customSanitizer(utils.addProtocol)
|
||||
.custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
|
||||
.withMessage("Homepage is not valid."),
|
||||
body("banned")
|
||||
.optional({ nullable: true })
|
||||
.customSanitizer(sanitizeCheckbox)
|
||||
.isBoolean(),
|
||||
]
|
||||
|
||||
const removeDomain = [
|
||||
param("id", "ID is invalid.")
|
||||
.exists({
|
||||
@ -196,6 +227,19 @@ const removeDomain = [
|
||||
.isLength({ min: 36, max: 36 })
|
||||
];
|
||||
|
||||
const removeDomainAdmin = [
|
||||
param("id", "ID is invalid.")
|
||||
.exists({
|
||||
checkFalsy: true,
|
||||
checkNull: true
|
||||
})
|
||||
.isNumeric(),
|
||||
queryValidator("links")
|
||||
.optional({ nullable: true })
|
||||
.customSanitizer(sanitizeCheckbox)
|
||||
.isBoolean(),
|
||||
];
|
||||
|
||||
const deleteLink = [
|
||||
param("id", "ID is invalid.")
|
||||
.exists({
|
||||
@ -251,6 +295,83 @@ const banLink = [
|
||||
.isBoolean()
|
||||
];
|
||||
|
||||
const banUser = [
|
||||
param("id", "ID is invalid.")
|
||||
.exists({
|
||||
checkFalsy: true,
|
||||
checkNull: true
|
||||
})
|
||||
.isNumeric(),
|
||||
body("links", '"links" should be a boolean.')
|
||||
.optional({
|
||||
nullable: true
|
||||
})
|
||||
.customSanitizer(sanitizeCheckbox)
|
||||
.isBoolean(),
|
||||
body("domains", '"domains" should be a boolean.')
|
||||
.optional({
|
||||
nullable: true
|
||||
})
|
||||
.customSanitizer(sanitizeCheckbox)
|
||||
.isBoolean()
|
||||
];
|
||||
|
||||
const banDomain = [
|
||||
param("id", "ID is invalid.")
|
||||
.exists({
|
||||
checkFalsy: true,
|
||||
checkNull: true
|
||||
})
|
||||
.isNumeric(),
|
||||
body("links", '"links" should be a boolean.')
|
||||
.optional({
|
||||
nullable: true
|
||||
})
|
||||
.customSanitizer(sanitizeCheckbox)
|
||||
.isBoolean(),
|
||||
body("domains", '"domains" should be a boolean.')
|
||||
.optional({
|
||||
nullable: true
|
||||
})
|
||||
.customSanitizer(sanitizeCheckbox)
|
||||
.isBoolean()
|
||||
];
|
||||
|
||||
const createUser = [
|
||||
body("password", "Password is not valid.")
|
||||
.exists({ checkFalsy: true, checkNull: true })
|
||||
.isLength({ min: 8, max: 64 })
|
||||
.withMessage("Password length must be between 8 and 64."),
|
||||
body("email", "Email is not valid.")
|
||||
.exists({ checkFalsy: true, checkNull: true })
|
||||
.trim()
|
||||
.isEmail()
|
||||
.isLength({ min: 0, max: 255 })
|
||||
.withMessage("Email length must be max 255.")
|
||||
.custom(async (value, { req }) => {
|
||||
const user = await query.user.find({ email: value });
|
||||
if (user)
|
||||
return Promise.reject();
|
||||
})
|
||||
.withMessage("User already exists."),
|
||||
body("role", "Role is not valid.")
|
||||
.optional({ nullable: true, checkFalsy: true })
|
||||
.trim()
|
||||
.isIn([ROLES.USER, ROLES.ADMIN]),
|
||||
body("verified")
|
||||
.optional({ nullable: true })
|
||||
.customSanitizer(sanitizeCheckbox)
|
||||
.isBoolean(),
|
||||
body("banned")
|
||||
.optional({ nullable: true })
|
||||
.customSanitizer(sanitizeCheckbox)
|
||||
.isBoolean(),
|
||||
body("verification_email")
|
||||
.optional({ nullable: true })
|
||||
.customSanitizer(sanitizeCheckbox)
|
||||
.isBoolean(),
|
||||
];
|
||||
|
||||
const getStats = [
|
||||
param("id", "ID is invalid.")
|
||||
.exists({
|
||||
@ -340,6 +461,12 @@ const deleteUser = [
|
||||
.withMessage("Password is not correct.")
|
||||
];
|
||||
|
||||
const deleteUserByAdmin = [
|
||||
param("id", "ID is invalid.")
|
||||
.exists({ checkFalsy: true, checkNull: true })
|
||||
.isNumeric()
|
||||
];
|
||||
|
||||
// TODO: if user has posted malware should do something better
|
||||
function cooldown(user) {
|
||||
|
||||
@ -429,7 +556,7 @@ async function bannedDomain(domain) {
|
||||
});
|
||||
|
||||
if (isBanned) {
|
||||
throw new utils.CustomError("URL is containing malware/scam.", 400);
|
||||
throw new utils.CustomError("Domain is banned.", 400);
|
||||
}
|
||||
};
|
||||
|
||||
@ -456,7 +583,10 @@ async function bannedHost(domain) {
|
||||
|
||||
module.exports = {
|
||||
addDomain,
|
||||
addDomainAdmin,
|
||||
banDomain,
|
||||
banLink,
|
||||
banUser,
|
||||
bannedDomain,
|
||||
bannedHost,
|
||||
changeEmail,
|
||||
@ -464,8 +594,10 @@ module.exports = {
|
||||
checkUser,
|
||||
cooldown,
|
||||
createLink,
|
||||
createUser,
|
||||
deleteLink,
|
||||
deleteUser,
|
||||
deleteUserByAdmin,
|
||||
editLink,
|
||||
getStats,
|
||||
linksCount,
|
||||
@ -473,6 +605,7 @@ module.exports = {
|
||||
malware,
|
||||
redirectProtected,
|
||||
removeDomain,
|
||||
removeDomainAdmin,
|
||||
reportLink,
|
||||
resetPassword,
|
||||
signup,
|
||||
|
41
server/migrations/20241103083933_user-roles.js
Normal file
41
server/migrations/20241103083933_user-roles.js
Normal file
@ -0,0 +1,41 @@
|
||||
const { ROLES } = require("../consts");
|
||||
const env = require("../env");
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
async function up(knex) {
|
||||
const hasRole = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasRole) {
|
||||
await knex.transaction(async function(trx) {
|
||||
await trx.schema.alterTable("users", table => {
|
||||
table
|
||||
.enu("role", [ROLES.USER, ROLES.ADMIN])
|
||||
.notNullable()
|
||||
.defaultTo(ROLES.USER);
|
||||
});
|
||||
const adminEmails = env.ADMIN_EMAILS.split(",").map((e) => e.trim());
|
||||
const adminRoleQuery = trx("users").update("role", ROLES.ADMIN);
|
||||
adminEmails.forEach((adminEmail, index) => {
|
||||
if (index === 0) {
|
||||
adminRoleQuery.where("email", adminEmail);
|
||||
} else {
|
||||
adminRoleQuery.orWhere("email", adminEmail);
|
||||
}
|
||||
});
|
||||
await adminRoleQuery;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
async function down(knex) {};
|
||||
|
||||
module.exports = {
|
||||
up,
|
||||
down,
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
const { ROLES } = require("../consts");
|
||||
|
||||
async function createUserTable(knex) {
|
||||
const hasTable = await knex.schema.hasTable("users");
|
||||
if (!hasTable) {
|
||||
@ -17,6 +19,10 @@ async function createUserTable(knex) {
|
||||
.string("email")
|
||||
.unique()
|
||||
.notNullable();
|
||||
table
|
||||
.enu("role", [ROLES.USER, ROLES.ADMIN])
|
||||
.notNullable()
|
||||
.defaultTo(ROLES.USER);
|
||||
table.string("password").notNullable();
|
||||
table.datetime("cooldown").nullable();
|
||||
table.integer("malicious_attempts").notNullable().defaultTo(0);
|
||||
|
@ -73,9 +73,147 @@ async function update(match, update) {
|
||||
return domains;
|
||||
}
|
||||
|
||||
function normalizeMatch(match) {
|
||||
const newMatch = { ...match };
|
||||
|
||||
if (newMatch.address) {
|
||||
newMatch["domains.address"] = newMatch.address;
|
||||
delete newMatch.address;
|
||||
}
|
||||
|
||||
if (newMatch.user_id) {
|
||||
newMatch["domains.user_id"] = newMatch.user_id;
|
||||
delete newMatch.user_id;
|
||||
}
|
||||
|
||||
if (newMatch.uuid) {
|
||||
newMatch["domains.uuid"] = newMatch.uuid;
|
||||
delete newMatch.uuid;
|
||||
}
|
||||
|
||||
if (newMatch.banned !== undefined) {
|
||||
newMatch["domains.banned"] = newMatch.banned;
|
||||
delete newMatch.banned;
|
||||
}
|
||||
|
||||
return newMatch;
|
||||
}
|
||||
|
||||
|
||||
const selectable_admin = [
|
||||
"domains.id",
|
||||
"domains.address",
|
||||
"domains.homepage",
|
||||
"domains.banned",
|
||||
"domains.created_at",
|
||||
"domains.updated_at",
|
||||
"domains.user_id",
|
||||
"domains.uuid",
|
||||
"users.email as email",
|
||||
"links_count"
|
||||
];
|
||||
|
||||
|
||||
async function getAdmin(match, params) {
|
||||
const query = knex("domains").select(...selectable_admin);
|
||||
|
||||
Object.entries(normalizeMatch(match)).forEach(([key, value]) => {
|
||||
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
|
||||
});
|
||||
|
||||
query
|
||||
.offset(params.skip)
|
||||
.limit(params.limit)
|
||||
.fromRaw("domains")
|
||||
.orderBy("domains.id", "desc")
|
||||
.groupBy(1)
|
||||
.groupBy("l.links_count")
|
||||
.groupBy("users.email");
|
||||
|
||||
if (params?.user) {
|
||||
const id = parseInt(params?.user);
|
||||
if (Number.isNaN(id)) {
|
||||
query.andWhereILike("users.email", "%" + params.user + "%");
|
||||
} else {
|
||||
query.andWhere("domains.user_id", id);
|
||||
}
|
||||
}
|
||||
|
||||
if (params?.search) {
|
||||
query.andWhereRaw(
|
||||
"concat_ws(' ', domains.address, domains.homepage) ILIKE '%' || ? || '%'",
|
||||
[params.search]
|
||||
);
|
||||
}
|
||||
|
||||
if (params?.links !== undefined) {
|
||||
query.andWhere("links_count", params?.links ? "is not" : "is", null);
|
||||
}
|
||||
|
||||
query.leftJoin(
|
||||
knex("links").select("domain_id").count("id as links_count").groupBy("domain_id").as("l"),
|
||||
"domains.id",
|
||||
"l.domain_id"
|
||||
);
|
||||
|
||||
query.leftJoin("users", "domains.user_id", "users.id");
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
async function totalAdmin(match, params) {
|
||||
const query = knex("domains");
|
||||
|
||||
Object.entries(normalizeMatch(match)).forEach(([key, value]) => {
|
||||
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
|
||||
});
|
||||
|
||||
if (params?.user) {
|
||||
const id = parseInt(params?.user);
|
||||
if (Number.isNaN(id)) {
|
||||
query.andWhereILike("users.email", "%" + params.user + "%");
|
||||
} else {
|
||||
query.andWhere("domains.user_id", id);
|
||||
}
|
||||
}
|
||||
|
||||
if (params?.search) {
|
||||
query.andWhereILike("domains.address", "%" + params.search + "%");
|
||||
}
|
||||
|
||||
if (params?.links !== undefined) {
|
||||
query.leftJoin(
|
||||
knex("links").select("domain_id").count("id as links_count").groupBy("domain_id").as("l"),
|
||||
"domains.id",
|
||||
"l.domain_id"
|
||||
);
|
||||
query.andWhere("links_count", params?.links ? "is not" : "is", null);
|
||||
}
|
||||
|
||||
query.leftJoin("users", "domains.user_id", "users.id");
|
||||
query.count("domains.id");
|
||||
|
||||
const [{ count }] = await query;
|
||||
|
||||
return typeof count === "number" ? count : parseInt(count);
|
||||
}
|
||||
|
||||
async function remove(domain) {
|
||||
const deletedDomain = await knex("domains").where("id", domain.id).delete();
|
||||
|
||||
if (env.REDIS_ENABLED) {
|
||||
redis.remove.domain(domain);
|
||||
}
|
||||
|
||||
return !!deletedDomain;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
add,
|
||||
find,
|
||||
get,
|
||||
getAdmin,
|
||||
remove,
|
||||
totalAdmin,
|
||||
update,
|
||||
}
|
@ -24,6 +24,11 @@ const selectable = [
|
||||
"domains.address as domain"
|
||||
];
|
||||
|
||||
const selectable_admin = [
|
||||
...selectable,
|
||||
"users.email as email"
|
||||
];
|
||||
|
||||
function normalizeMatch(match) {
|
||||
const newMatch = { ...match };
|
||||
|
||||
@ -42,6 +47,11 @@ function normalizeMatch(match) {
|
||||
delete newMatch.uuid;
|
||||
}
|
||||
|
||||
if (newMatch.banned !== undefined) {
|
||||
newMatch["links.banned"] = newMatch.banned;
|
||||
delete newMatch.banned;
|
||||
}
|
||||
|
||||
return newMatch;
|
||||
}
|
||||
|
||||
@ -67,13 +77,49 @@ async function total(match, params) {
|
||||
return typeof count === "number" ? count : parseInt(count);
|
||||
}
|
||||
|
||||
async function totalAdmin(match, params) {
|
||||
const query = knex("links");
|
||||
|
||||
Object.entries(normalizeMatch(match)).forEach(([key, value]) => {
|
||||
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
|
||||
});
|
||||
|
||||
if (params?.user) {
|
||||
const id = parseInt(params?.user);
|
||||
if (Number.isNaN(id)) {
|
||||
query.andWhereILike("users.email", "%" + params.user + "%");
|
||||
} else {
|
||||
query.andWhere("links.user_id", params.user);
|
||||
}
|
||||
}
|
||||
|
||||
if (params?.search) {
|
||||
query.andWhereRaw(
|
||||
"concat_ws(' ', description, links.address, target) ILIKE '%' || ? || '%'",
|
||||
[params.search]
|
||||
);
|
||||
}
|
||||
|
||||
if (params?.domain) {
|
||||
query.andWhereRaw("domains.address ILIKE '%' || ? || '%'", [params.domain]);
|
||||
}
|
||||
|
||||
query.leftJoin("domains", "links.domain_id", "domains.id");
|
||||
query.leftJoin("users", "links.user_id", "users.id");
|
||||
query.count("links.id");
|
||||
|
||||
const [{ count }] = await query;
|
||||
|
||||
return typeof count === "number" ? count : parseInt(count);
|
||||
}
|
||||
|
||||
async function get(match, params) {
|
||||
const query = knex("links")
|
||||
.select(...selectable)
|
||||
.where(normalizeMatch(match))
|
||||
.offset(params.skip)
|
||||
.limit(params.limit)
|
||||
.orderBy("links.created_at", "desc");
|
||||
.orderBy("links.id", "desc");
|
||||
|
||||
if (params?.search) {
|
||||
query.andWhereRaw(
|
||||
@ -87,6 +133,44 @@ async function get(match, params) {
|
||||
return query;
|
||||
}
|
||||
|
||||
async function getAdmin(match, params) {
|
||||
const query = knex("links").select(...selectable_admin);
|
||||
|
||||
Object.entries(normalizeMatch(match)).forEach(([key, value]) => {
|
||||
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
|
||||
});
|
||||
|
||||
query
|
||||
.orderBy("links.id", "desc")
|
||||
.offset(params.skip)
|
||||
.limit(params.limit)
|
||||
|
||||
if (params?.user) {
|
||||
const id = parseInt(params?.user);
|
||||
if (Number.isNaN(id)) {
|
||||
query.andWhereILike("users.email", "%" + params.user + "%");
|
||||
} else {
|
||||
query.andWhere("links.user_id", params.user);
|
||||
}
|
||||
}
|
||||
|
||||
if (params?.search) {
|
||||
query.andWhereRaw(
|
||||
"concat_ws(' ', description, links.address, target) ILIKE '%' || ? || '%'",
|
||||
[params.search]
|
||||
);
|
||||
}
|
||||
|
||||
if (params?.domain) {
|
||||
query.andWhereRaw("domains.address ILIKE '%' || ? || '%'", [params.domain]);
|
||||
}
|
||||
|
||||
query.leftJoin("domains", "links.domain_id", "domains.id");
|
||||
query.leftJoin("users", "links.user_id", "users.id");
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
async function find(match) {
|
||||
if (match.address && match.domain_id && env.REDIS_ENABLED) {
|
||||
const key = redis.key.link(match.address, match.domain_id);
|
||||
@ -151,17 +235,15 @@ async function remove(match) {
|
||||
}
|
||||
|
||||
async function batchRemove(match) {
|
||||
const deleteQuery = knex("links");
|
||||
const findQuery = knex("links");
|
||||
const query = knex("links");
|
||||
|
||||
Object.entries(match).forEach(([key, value]) => {
|
||||
findQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
|
||||
deleteQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
|
||||
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
|
||||
});
|
||||
|
||||
const links = await findQuery;
|
||||
const links = await query.clone();
|
||||
|
||||
await deleteQuery.delete();
|
||||
await query.delete();
|
||||
|
||||
if (env.REDIS_ENABLED) {
|
||||
links.forEach(redis.remove.link);
|
||||
@ -197,8 +279,10 @@ module.exports = {
|
||||
create,
|
||||
find,
|
||||
get,
|
||||
getAdmin,
|
||||
incrementVisit,
|
||||
remove,
|
||||
total,
|
||||
totalAdmin,
|
||||
update,
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
const { addMinutes } = require("date-fns");
|
||||
const { v4: uuid } = require("uuid");
|
||||
|
||||
const { ROLES } = require("../consts");
|
||||
const utils = require("../utils");
|
||||
const redis = require("../redis");
|
||||
const knex = require("../knex");
|
||||
@ -94,9 +95,133 @@ async function remove(user) {
|
||||
return !!deletedUser;
|
||||
}
|
||||
|
||||
const selectable_admin = [
|
||||
"users.id",
|
||||
"users.email",
|
||||
"users.verified",
|
||||
"users.role",
|
||||
"users.banned",
|
||||
"users.banned_by_id",
|
||||
"users.created_at",
|
||||
"users.updated_at"
|
||||
];
|
||||
|
||||
function normalizeMatch(match) {
|
||||
const newMatch = { ...match }
|
||||
|
||||
if (newMatch.banned !== undefined) {
|
||||
newMatch["users.banned"] = newMatch.banned;
|
||||
delete newMatch.banned;
|
||||
}
|
||||
|
||||
return newMatch;
|
||||
}
|
||||
|
||||
async function getAdmin(match, params) {
|
||||
const query = knex("users")
|
||||
.select(...selectable_admin)
|
||||
.select("l.links_count")
|
||||
.select("d.domains")
|
||||
.fromRaw("users")
|
||||
.where(normalizeMatch(match))
|
||||
.offset(params.skip)
|
||||
.limit(params.limit)
|
||||
.orderBy("users.id", "desc")
|
||||
.groupBy(1)
|
||||
.groupBy("l.links_count")
|
||||
.groupBy("d.domains");
|
||||
|
||||
if (params?.search) {
|
||||
const id = parseInt(params?.search);
|
||||
if (Number.isNaN(id)) {
|
||||
query.andWhereILike("users.email", "%" + params?.search + "%");
|
||||
} else {
|
||||
query.andWhere("users.id", params?.search);
|
||||
}
|
||||
}
|
||||
|
||||
if (params?.domains !== undefined) {
|
||||
query.andWhere("d.domains", params?.domains ? "is not" : "is", null);
|
||||
}
|
||||
|
||||
if (params?.links !== undefined) {
|
||||
query.andWhere("links_count", params?.links ? "is not" : "is", null);
|
||||
}
|
||||
|
||||
query.leftJoin(
|
||||
knex("domains")
|
||||
.select("user_id", knex.raw("string_agg(address, ', ') AS domains"))
|
||||
.groupBy("user_id").as("d"),
|
||||
"users.id",
|
||||
"d.user_id"
|
||||
)
|
||||
query.leftJoin(
|
||||
knex("links").select("user_id").count("id as links_count").groupBy("user_id").as("l"),
|
||||
"users.id",
|
||||
"l.user_id"
|
||||
);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
async function totalAdmin(match, params) {
|
||||
const query = knex("users")
|
||||
.count("users.id")
|
||||
.fromRaw('users')
|
||||
.where(normalizeMatch(match));
|
||||
|
||||
if (params?.search) {
|
||||
const id = parseInt(params?.search);
|
||||
if (Number.isNaN(id)) {
|
||||
query.andWhereILike("users.email", "%" + params?.search + "%");
|
||||
} else {
|
||||
query.andWhere("users.id", params?.search);
|
||||
}
|
||||
}
|
||||
|
||||
if (params?.domains !== undefined) {
|
||||
query.andWhere("domains", params?.domains ? "is not" : "is", null);
|
||||
query.leftJoin(
|
||||
knex("domains")
|
||||
.select("user_id", knex.raw("string_agg(address, ', ') AS domains"))
|
||||
.groupBy("user_id").as("d"),
|
||||
"users.id",
|
||||
"d.user_id"
|
||||
);
|
||||
}
|
||||
|
||||
if (params?.links !== undefined) {
|
||||
query.andWhere("links", params?.links ? "is not" : "is", null);
|
||||
query.leftJoin(
|
||||
knex("links").select("user_id").count("id as links").groupBy("user_id").as("l"),
|
||||
"users.id",
|
||||
"l.user_id"
|
||||
);
|
||||
}
|
||||
|
||||
const [{count}] = await query;
|
||||
|
||||
return typeof count === "number" ? count : parseInt(count);
|
||||
}
|
||||
|
||||
async function create(params) {
|
||||
const [user] = await knex("users").insert({
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
role: params.role ?? ROLES.USER,
|
||||
verified: params.verified ?? false,
|
||||
banned: params.banned ?? false,
|
||||
}, "*");
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
add,
|
||||
create,
|
||||
find,
|
||||
getAdmin,
|
||||
remove,
|
||||
totalAdmin,
|
||||
update,
|
||||
}
|
||||
|
@ -9,6 +9,17 @@ const auth = require("../handlers/auth.handler");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
"/admin",
|
||||
locals.viewTemplate("partials/admin/domains/table"),
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
helpers.parseQuery,
|
||||
locals.adminTable,
|
||||
asyncHandler(domains.getAdmin)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
locals.viewTemplate("partials/settings/domain/add_form"),
|
||||
@ -19,6 +30,17 @@ router.post(
|
||||
asyncHandler(domains.add)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/admin",
|
||||
locals.viewTemplate("partials/admin/dialog/add_domain"),
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
validators.addDomainAdmin,
|
||||
asyncHandler(helpers.verify),
|
||||
asyncHandler(domains.addAdmin)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:id",
|
||||
locals.viewTemplate("partials/settings/domain/delete"),
|
||||
@ -29,4 +51,26 @@ router.delete(
|
||||
asyncHandler(domains.remove)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/admin/:id",
|
||||
locals.viewTemplate("partials/admin/dialog/delete_domain"),
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
validators.removeDomainAdmin,
|
||||
asyncHandler(helpers.verify),
|
||||
asyncHandler(domains.removeAdmin)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/admin/ban/:id",
|
||||
locals.viewTemplate("partials/admin/dialog/ban_domain"),
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
validators.banDomain,
|
||||
asyncHandler(helpers.verify),
|
||||
asyncHandler(domains.ban)
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
@ -2,7 +2,6 @@ const { Router } = require("express");
|
||||
const cors = require("cors");
|
||||
|
||||
const validators = require("../handlers/validators.handler");
|
||||
|
||||
const helpers = require("../handlers/helpers.handler");
|
||||
const asyncHandler = require("../utils/asyncHandler");
|
||||
const locals = require("../handlers/locals.handler");
|
||||
@ -21,6 +20,17 @@ router.get(
|
||||
asyncHandler(link.get)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/admin",
|
||||
locals.viewTemplate("partials/admin/links/table"),
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
helpers.parseQuery,
|
||||
locals.adminTable,
|
||||
asyncHandler(link.getAdmin)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
cors(),
|
||||
@ -45,6 +55,18 @@ router.patch(
|
||||
asyncHandler(link.edit)
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/admin/:id",
|
||||
locals.viewTemplate("partials/links/edit"),
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
locals.editLink,
|
||||
validators.editLink,
|
||||
asyncHandler(helpers.verify),
|
||||
asyncHandler(link.editAdmin)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:id",
|
||||
locals.viewTemplate("partials/links/dialog/delete"),
|
||||
|
@ -42,6 +42,14 @@ router.get(
|
||||
asyncHandler(renders.settings)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/admin",
|
||||
asyncHandler(auth.jwtPage),
|
||||
asyncHandler(auth.admin),
|
||||
asyncHandler(locals.user),
|
||||
asyncHandler(renders.admin)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/stats",
|
||||
asyncHandler(auth.jwtPage),
|
||||
@ -119,6 +127,56 @@ router.get(
|
||||
asyncHandler(renders.confirmLinkBan)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/confirm-user-delete",
|
||||
locals.noLayout,
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
asyncHandler(renders.confirmUserDelete)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/confirm-user-ban",
|
||||
locals.noLayout,
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
asyncHandler(renders.confirmUserBan)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/create-user",
|
||||
locals.noLayout,
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
asyncHandler(renders.createUser)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/add-domain",
|
||||
locals.noLayout,
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
asyncHandler(renders.addDomainAdmin)
|
||||
);
|
||||
|
||||
|
||||
router.get(
|
||||
"/confirm-domain-ban",
|
||||
locals.noLayout,
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
asyncHandler(renders.confirmDomainBan)
|
||||
);
|
||||
|
||||
|
||||
router.get(
|
||||
"/confirm-domain-delete-admin",
|
||||
locals.noLayout,
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
asyncHandler(renders.confirmDomainDeleteAdmin)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/link/edit/:id",
|
||||
locals.noLayout,
|
||||
@ -126,6 +184,14 @@ router.get(
|
||||
asyncHandler(renders.linkEdit)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/admin/link/edit/:id",
|
||||
locals.noLayout,
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
asyncHandler(renders.linkEditAdmin)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/add-domain-form",
|
||||
locals.noLayout,
|
||||
|
@ -16,6 +16,28 @@ router.get(
|
||||
asyncHandler(user.get)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/admin",
|
||||
locals.viewTemplate("partials/admin/users/table"),
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
helpers.parseQuery,
|
||||
locals.adminTable,
|
||||
asyncHandler(user.getAdmin)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/admin",
|
||||
locals.viewTemplate("partials/admin/dialog/create_user"),
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
validators.createUser,
|
||||
asyncHandler(helpers.verify),
|
||||
asyncHandler(user.create)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/delete",
|
||||
locals.viewTemplate("partials/settings/delete_account"),
|
||||
@ -26,4 +48,26 @@ router.post(
|
||||
asyncHandler(user.remove)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/admin/:id",
|
||||
locals.viewTemplate("partials/admin/dialog/delete_user"),
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
validators.deleteUserByAdmin,
|
||||
asyncHandler(helpers.verify),
|
||||
asyncHandler(user.removeByAdmin)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/admin/ban/:id",
|
||||
locals.viewTemplate("partials/admin/dialog/ban_user"),
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwt),
|
||||
asyncHandler(auth.admin),
|
||||
validators.banUser,
|
||||
asyncHandler(helpers.verify),
|
||||
asyncHandler(user.ban)
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
@ -7,6 +7,7 @@ const path = require("path");
|
||||
const hbs = require("hbs");
|
||||
const ms = require("ms");
|
||||
|
||||
const { ROLES } = require("../consts");
|
||||
const env = require("../env");
|
||||
|
||||
class CustomError extends Error {
|
||||
@ -20,10 +21,8 @@ class CustomError extends Error {
|
||||
|
||||
const urlRegex = /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i;
|
||||
|
||||
function isAdmin(email) {
|
||||
return env.ADMIN_EMAILS.split(",")
|
||||
.map((e) => e.trim())
|
||||
.includes(email)
|
||||
function isAdmin(user) {
|
||||
return user.role === ROLES.ADMIN;
|
||||
}
|
||||
|
||||
function signToken(user) {
|
||||
@ -70,7 +69,7 @@ function getShortURL(address, domain) {
|
||||
const protocol = (env.CUSTOM_DOMAIN_USE_HTTPS || !domain) && !env.isDev ? "https://" : "http://";
|
||||
const link = `${domain || env.DEFAULT_DOMAIN}/${address}`;
|
||||
const url = `${protocol}${link}`;
|
||||
return { link, url };
|
||||
return { address, link, url };
|
||||
}
|
||||
|
||||
function getStatsLimit() {
|
||||
@ -174,6 +173,12 @@ const preservedURLs = [
|
||||
"pricing"
|
||||
];
|
||||
|
||||
function parseBooleanQuery(query) {
|
||||
if (query === "true" || query === true) return true;
|
||||
if (query === "false" || query === false) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getInitStats() {
|
||||
return Object.create({
|
||||
browser: {
|
||||
@ -257,8 +262,43 @@ const sanitize = {
|
||||
relative_created_at: getTimeAgo(timestamps.created_at),
|
||||
relative_expire_in: link.expire_in && ms(differenceInMilliseconds(parseDatetime(link.expire_in), new Date()), { long: true }),
|
||||
password: !!link.password,
|
||||
visit_count: link.visit_count.toLocaleString("en-US"),
|
||||
link: getShortURL(link.address, link.domain)
|
||||
}
|
||||
},
|
||||
link_admin: link => {
|
||||
const timestamps = parseTimestamps(link);
|
||||
return {
|
||||
...link,
|
||||
...timestamps,
|
||||
domain: link.domain || env.DEFAULT_DOMAIN,
|
||||
id: link.uuid,
|
||||
relative_created_at: getTimeAgo(timestamps.created_at),
|
||||
relative_expire_in: link.expire_in && ms(differenceInMilliseconds(parseDatetime(link.expire_in), new Date()), { long: true }),
|
||||
password: !!link.password,
|
||||
visit_count: link.visit_count.toLocaleString("en-US"),
|
||||
link: getShortURL(link.address, link.domain)
|
||||
}
|
||||
},
|
||||
user_admin: user => {
|
||||
const timestamps = parseTimestamps(user);
|
||||
return {
|
||||
...user,
|
||||
...timestamps,
|
||||
links_count: (user.links_count ?? 0).toLocaleString("en-US"),
|
||||
relative_created_at: getTimeAgo(timestamps.created_at),
|
||||
relative_updated_at: getTimeAgo(timestamps.updated_at),
|
||||
}
|
||||
},
|
||||
domain_admin: domain => {
|
||||
const timestamps = parseTimestamps(domain);
|
||||
return {
|
||||
...domain,
|
||||
...timestamps,
|
||||
links_count: (domain.links_count ?? 0).toLocaleString("en-US"),
|
||||
relative_created_at: getTimeAgo(timestamps.created_at),
|
||||
relative_updated_at: getTimeAgo(timestamps.updated_at),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -309,6 +349,7 @@ module.exports = {
|
||||
getStatsLimit,
|
||||
getStatsPeriods,
|
||||
isAdmin,
|
||||
parseBooleanQuery,
|
||||
parseDatetime,
|
||||
parseTimestamps,
|
||||
preservedURLs,
|
||||
|
3
server/views/admin.hbs
Normal file
3
server/views/admin.hbs
Normal file
@ -0,0 +1,3 @@
|
||||
{{> header}}
|
||||
{{> admin/index}}
|
||||
{{> footer}}
|
56
server/views/partials/admin/dialog/add_domain.hbs
Normal file
56
server/views/partials/admin/dialog/add_domain.hbs
Normal file
@ -0,0 +1,56 @@
|
||||
<div class="content admin-create">
|
||||
<h2>Add domain</h2>
|
||||
<form
|
||||
id="add-domain-form"
|
||||
hx-post="/api/domains/admin"
|
||||
hx-target="closest .content"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="closest .content"
|
||||
>
|
||||
<label class="{{#if errors.address}}error{{/if}}">
|
||||
Address:
|
||||
<input
|
||||
name="address"
|
||||
id="add-domain-address"
|
||||
type="text"
|
||||
placeholder="Address..."
|
||||
hx-preserve="true"
|
||||
/>
|
||||
{{#if errors.address}}<p class="error">{{errors.address}}</p>{{/if}}
|
||||
</label>
|
||||
<label class="{{#if errors.homepage}}error{{/if}}">
|
||||
Homepage (optional):
|
||||
<input
|
||||
name="homepage"
|
||||
id="add-domain-homepage"
|
||||
type="text"
|
||||
placeholder="Homepage address.."
|
||||
hx-preserve="true"
|
||||
/>
|
||||
{{#if errors.homepage}}<p class="error">{{errors.homepage}}</p>{{/if}}
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input
|
||||
id="add-domain-banned"
|
||||
name="banned"
|
||||
type="checkbox"
|
||||
onchange="canSendVerificationEmail();"
|
||||
hx-preserve="true"
|
||||
/>
|
||||
Banned
|
||||
</label>
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Cancel</button>
|
||||
<button type="submit" class="primary">
|
||||
<span>{{> icons/plus}}</span>
|
||||
Add
|
||||
</button>
|
||||
{{> icons/spinner}}
|
||||
</div>
|
||||
</form>
|
||||
<div id="dialog-error">
|
||||
{{#if error}}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
12
server/views/partials/admin/dialog/add_domain_success.hbs
Normal file
12
server/views/partials/admin/dialog/add_domain_success.hbs
Normal file
@ -0,0 +1,12 @@
|
||||
<div class="content">
|
||||
<div class="icon success">
|
||||
{{> icons/check}}
|
||||
</div>
|
||||
<p>
|
||||
The domain <b>"{{address}}"</b> has been created successfully.
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
46
server/views/partials/admin/dialog/ban_domain.hbs
Normal file
46
server/views/partials/admin/dialog/ban_domain.hbs
Normal file
@ -0,0 +1,46 @@
|
||||
<div class="content">
|
||||
<h2>Ban domain?</h2>
|
||||
<p>
|
||||
Are you sure do you want to ban the domain "<b>{{address}}</b>"?
|
||||
</p>
|
||||
<div class="ban-checklist">
|
||||
{{#if hasUser}}
|
||||
<label class="checkbox">
|
||||
<input id="ban-domain-user" name="user" type="checkbox" />
|
||||
Owner
|
||||
</label>
|
||||
{{/if}}
|
||||
{{#if hasLink}}
|
||||
<label class="checkbox">
|
||||
<input id="ban-domain-links" name="links" type="checkbox" />
|
||||
Links
|
||||
</label>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger confirm"
|
||||
hx-post="/api/domains/admin/ban/{id}"
|
||||
hx-ext="path-params"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
hx-target="closest .content"
|
||||
hx-swap="none"
|
||||
hx-include=".ban-checklist"
|
||||
hx-indicator="closest .content"
|
||||
hx-select-oob="#dialog-error"
|
||||
>
|
||||
<span class="stop">
|
||||
{{> icons/stop}}
|
||||
</span>
|
||||
Ban
|
||||
</button>
|
||||
{{> icons/spinner}}
|
||||
</div>
|
||||
<div id="dialog-error">
|
||||
{{#if error}}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
12
server/views/partials/admin/dialog/ban_domain_success.hbs
Normal file
12
server/views/partials/admin/dialog/ban_domain_success.hbs
Normal file
@ -0,0 +1,12 @@
|
||||
<div class="content">
|
||||
<div class="icon success">
|
||||
{{> icons/check}}
|
||||
</div>
|
||||
<p>
|
||||
The domain <b>"{{address}}"</b> is banned.
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
42
server/views/partials/admin/dialog/ban_user.hbs
Normal file
42
server/views/partials/admin/dialog/ban_user.hbs
Normal file
@ -0,0 +1,42 @@
|
||||
<div class="content">
|
||||
<h2>Ban user?</h2>
|
||||
<p>
|
||||
Are you sure do you want to ban the user "<b>{{email}}</b>"?
|
||||
</p>
|
||||
<div class="ban-checklist">
|
||||
<label class="checkbox">
|
||||
<input id="ban-user-links" name="links" type="checkbox" />
|
||||
User links
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input id="ban-user-domains" name="domains" type="checkbox" />
|
||||
User domains
|
||||
</label>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger confirm"
|
||||
hx-post="/api/users/admin/ban/{id}"
|
||||
hx-ext="path-params"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
hx-target="closest .content"
|
||||
hx-swap="none"
|
||||
hx-include=".ban-checklist"
|
||||
hx-indicator="closest .content"
|
||||
hx-select-oob="#dialog-error"
|
||||
>
|
||||
<span class="stop">
|
||||
{{> icons/stop}}
|
||||
</span>
|
||||
Ban
|
||||
</button>
|
||||
{{> icons/spinner}}
|
||||
</div>
|
||||
<div id="dialog-error">
|
||||
{{#if error}}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
12
server/views/partials/admin/dialog/ban_user_success.hbs
Normal file
12
server/views/partials/admin/dialog/ban_user_success.hbs
Normal file
@ -0,0 +1,12 @@
|
||||
<div class="content">
|
||||
<div class="icon success">
|
||||
{{> icons/check}}
|
||||
</div>
|
||||
<p>
|
||||
The user <b>"{{email}}"</b> is banned.
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
81
server/views/partials/admin/dialog/create_user.hbs
Normal file
81
server/views/partials/admin/dialog/create_user.hbs
Normal file
@ -0,0 +1,81 @@
|
||||
<div class="content create-user">
|
||||
<h2>Create user</h2>
|
||||
<form
|
||||
id="create-user-form"
|
||||
hx-post="/api/users/admin"
|
||||
hx-target="closest .content"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="closest .content"
|
||||
>
|
||||
<label class="{{#if errors.email}}error{{/if}}">
|
||||
Email address:
|
||||
<input
|
||||
name="email"
|
||||
id="create-user-email"
|
||||
type="email"
|
||||
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="create-user-password"
|
||||
type="password"
|
||||
placeholder="Password..."
|
||||
hx-preserve="true"
|
||||
/>
|
||||
{{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
|
||||
</label>
|
||||
<label class="{{#if errors.role}}error{{/if}}">
|
||||
Role:
|
||||
<select name="role" id="create-user-role" hx-preserve="true">
|
||||
<option value="USER" selected>User</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
</select>
|
||||
{{#if errors.role}}<p class="error">{{errors.role}}</p>{{/if}}
|
||||
</label>
|
||||
<div class="checkbox-wrapper">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
id="create-user-verified"
|
||||
name="verified"
|
||||
type="checkbox"
|
||||
onchange="canSendVerificationEmail();"
|
||||
hx-preserve="true"
|
||||
checked
|
||||
/>
|
||||
Verified
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input
|
||||
id="create-user-banned"
|
||||
name="banned"
|
||||
type="checkbox"
|
||||
onchange="canSendVerificationEmail();"
|
||||
hx-preserve="true"
|
||||
/>
|
||||
Banned
|
||||
</label>
|
||||
</div>
|
||||
<label id="send-email-label" class="checkbox hidden" hx-preserve="true">
|
||||
<input id="create-user-send-email" name="verification_email" type="checkbox" />
|
||||
Send verification email
|
||||
</label>
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Cancel</button>
|
||||
<button type="submit" class="primary">
|
||||
<span>{{> icons/new_user}}</span>
|
||||
Create
|
||||
</button>
|
||||
{{> icons/spinner}}
|
||||
</div>
|
||||
</form>
|
||||
<div id="dialog-error">
|
||||
{{#if error}}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
12
server/views/partials/admin/dialog/create_user_success.hbs
Normal file
12
server/views/partials/admin/dialog/create_user_success.hbs
Normal file
@ -0,0 +1,12 @@
|
||||
<div class="content">
|
||||
<div class="icon success">
|
||||
{{> icons/check}}
|
||||
</div>
|
||||
<p>
|
||||
The user <b>"{{email}}"</b> has been created successfully.
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
40
server/views/partials/admin/dialog/delete_domain.hbs
Normal file
40
server/views/partials/admin/dialog/delete_domain.hbs
Normal file
@ -0,0 +1,40 @@
|
||||
<div class="content">
|
||||
<h2>Delete domain?</h2>
|
||||
<p>
|
||||
Are you sure do you want to delete the domain "<b>{{address}}</b>"?<br/>
|
||||
</p>
|
||||
{{#if hasLink}}
|
||||
<div class="delete-domain-checklist">
|
||||
<label class="checkbox">
|
||||
<input id="delete-domain-links" name="links" type="checkbox" />
|
||||
Delete all links too
|
||||
</label>
|
||||
</div>
|
||||
{{/if }}
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger confirm"
|
||||
hx-delete="/api/domains/admin/{id}"
|
||||
hx-ext="path-params"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
hx-target="closest .content"
|
||||
hx-swap="none"
|
||||
{{#if hasLink}}
|
||||
hx-include=".delete-domain-checklist"
|
||||
{{/if}}
|
||||
hx-indicator="closest .content"
|
||||
hx-select-oob="#dialog-error"
|
||||
>
|
||||
<span>{{> icons/trash}}</span>
|
||||
Delete
|
||||
</button>
|
||||
{{> icons/spinner}}
|
||||
</div>
|
||||
<div id="dialog-error">
|
||||
{{#if error}}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
12
server/views/partials/admin/dialog/delete_domain_success.hbs
Normal file
12
server/views/partials/admin/dialog/delete_domain_success.hbs
Normal file
@ -0,0 +1,12 @@
|
||||
<div class="content">
|
||||
<div class="icon success">
|
||||
{{> icons/check}}
|
||||
</div>
|
||||
<p>
|
||||
The domain <b>"{{address}}"</b> has been deleted.
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
30
server/views/partials/admin/dialog/delete_user.hbs
Normal file
30
server/views/partials/admin/dialog/delete_user.hbs
Normal file
@ -0,0 +1,30 @@
|
||||
<div class="content">
|
||||
<h2>Delete user?</h2>
|
||||
<p>
|
||||
Are you sure do you want to delete the user "<b>{{email}}</b>"?<br/>
|
||||
<b>All their data including their links</b> will be deleted.
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger confirm"
|
||||
hx-delete="/api/users/admin/{id}"
|
||||
hx-ext="path-params"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
hx-target="closest .content"
|
||||
hx-swap="none"
|
||||
hx-indicator="closest .content"
|
||||
hx-select-oob="#dialog-error"
|
||||
>
|
||||
<span>{{> icons/trash}}</span>
|
||||
Delete
|
||||
</button>
|
||||
{{> icons/spinner}}
|
||||
</div>
|
||||
<div id="dialog-error">
|
||||
{{#if error}}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
12
server/views/partials/admin/dialog/delete_user_success.hbs
Normal file
12
server/views/partials/admin/dialog/delete_user_success.hbs
Normal file
@ -0,0 +1,12 @@
|
||||
<div class="content">
|
||||
<div class="icon success">
|
||||
{{> icons/check}}
|
||||
</div>
|
||||
<p>
|
||||
The user <b>"{{email}}"</b> has been deleted.
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
8
server/views/partials/admin/dialog/frame.hbs
Normal file
8
server/views/partials/admin/dialog/frame.hbs
Normal file
@ -0,0 +1,8 @@
|
||||
<div id="admin-table-dialog" class="dialog">
|
||||
<div class="box">
|
||||
<div class="content-wrapper"></div>
|
||||
<div class="loading">
|
||||
{{> icons/spinner}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
11
server/views/partials/admin/dialog/mesasge.hbs
Normal file
11
server/views/partials/admin/dialog/mesasge.hbs
Normal file
@ -0,0 +1,11 @@
|
||||
<div class="content">
|
||||
{{#if error}}
|
||||
<p>{{error}}</p>
|
||||
{{else}}
|
||||
<p>{{message}}</p>
|
||||
{{/if}}
|
||||
<div class="buttons">
|
||||
<button type="button" hx-on:click="closeDialog()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
29
server/views/partials/admin/domains/actions.hbs
Normal file
29
server/views/partials/admin/domains/actions.hbs
Normal file
@ -0,0 +1,29 @@
|
||||
<td class="actions domains-actions">
|
||||
{{#if banned}}
|
||||
<button class="action banned" disabled="true" data-tooltip="Banned">
|
||||
{{> icons/stop}}
|
||||
</button>
|
||||
{{/if}}
|
||||
{{#unless banned}}
|
||||
<button
|
||||
class="action ban"
|
||||
hx-on:click='openDialog("admin-table-dialog")'
|
||||
hx-get="/confirm-domain-ban"
|
||||
hx-target="#admin-table-dialog .content-wrapper"
|
||||
hx-indicator="#admin-table-dialog"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
>
|
||||
{{> icons/stop}}
|
||||
</button>
|
||||
{{/unless}}
|
||||
<button
|
||||
class="action delete"
|
||||
hx-on:click='openDialog("admin-table-dialog")'
|
||||
hx-get="/confirm-domain-delete-admin"
|
||||
hx-target="#admin-table-dialog .content-wrapper"
|
||||
hx-indicator="#admin-table-dialog"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
>
|
||||
{{> icons/trash}}
|
||||
</button>
|
||||
</td>
|
16
server/views/partials/admin/domains/loading.hbs
Normal file
16
server/views/partials/admin/domains/loading.hbs
Normal file
@ -0,0 +1,16 @@
|
||||
{{#unless table_domains}}
|
||||
{{#ifEquals table_domains.length 0}}
|
||||
<tr class="no-data">
|
||||
<td>
|
||||
No domains.
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr class="loading-placeholder">
|
||||
<td>
|
||||
{{> icons/spinner}}
|
||||
Loading domains...
|
||||
</td>
|
||||
</tr>
|
||||
{{/ifEquals}}
|
||||
{{/unless}}
|
30
server/views/partials/admin/domains/table.hbs
Normal file
30
server/views/partials/admin/domains/table.hbs
Normal file
@ -0,0 +1,30 @@
|
||||
<table
|
||||
hx-get="/api/domains/admin"
|
||||
hx-target="tbody"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="tbody"
|
||||
hx-disinherit="*"
|
||||
hx-include=".domains-controls"
|
||||
hx-params="not total"
|
||||
hx-sync="this:replace"
|
||||
hx-select-oob="#total,#category-total"
|
||||
hx-trigger="
|
||||
{{#if onload}}load once,{{/if}}
|
||||
reloadMainTable from:body,
|
||||
click delay:100ms from:button.nav,
|
||||
input changed delay:500ms from:[name='search'],
|
||||
input changed delay:500ms from:[name='user'],
|
||||
input changed from:[name='banned'],
|
||||
input changed from:[name='links'],
|
||||
input changed from:[name='owner'],
|
||||
"
|
||||
hx-on:htmx:after-on-load="updateLinksNav()"
|
||||
hx-on:htmx:after-settle="onSearchInputLoad();"
|
||||
>
|
||||
{{> admin/domains/thead}}
|
||||
{{> admin/domains/tbody}}
|
||||
{{> admin/domains/tfoot}}
|
||||
</table>
|
||||
<template>
|
||||
<h2 id="admin-table-title" hx-swap-oob="true">Recent added domains.</h2>
|
||||
</template>
|
6
server/views/partials/admin/domains/tbody.hbs
Normal file
6
server/views/partials/admin/domains/tbody.hbs
Normal file
@ -0,0 +1,6 @@
|
||||
<tbody>
|
||||
{{> admin/domains/loading}}
|
||||
{{#each table_domains}}
|
||||
{{> admin/domains/tr}}
|
||||
{{/each}}
|
||||
</tbody>
|
5
server/views/partials/admin/domains/tfoot.hbs
Normal file
5
server/views/partials/admin/domains/tfoot.hbs
Normal file
@ -0,0 +1,5 @@
|
||||
<tfoot>
|
||||
<tr class="controls domains-controls">
|
||||
{{> admin/table_nav}}
|
||||
</tr>
|
||||
</tfoot>
|
88
server/views/partials/admin/domains/thead.hbs
Normal file
88
server/views/partials/admin/domains/thead.hbs
Normal file
@ -0,0 +1,88 @@
|
||||
<thead>
|
||||
{{> admin/table_tab title='domains'}}
|
||||
<tr class="controls domains-controls with-filters">
|
||||
<th class="filters">
|
||||
<div>
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
id="search"
|
||||
name="search"
|
||||
type="text"
|
||||
placeholder="Search domain..."
|
||||
class="table-input search admin"
|
||||
hx-on:input="onSearchChange(event)"
|
||||
hx-on:keyup="resetTableNav()"
|
||||
value="{{query.search}}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
class="clear"
|
||||
onclick="clearSeachInput(event)"
|
||||
>
|
||||
{{> icons/x}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
id="search_user"
|
||||
name="user"
|
||||
type="text"
|
||||
placeholder="Search user..."
|
||||
class="table-input search admin"
|
||||
hx-on:input="onSearchChange(event)"
|
||||
hx-on:keyup="resetTableNav()"
|
||||
value="{{query.user}}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear user"
|
||||
class="clear"
|
||||
onclick="clearSeachInput(event)"
|
||||
>
|
||||
{{> icons/x}}
|
||||
</button>
|
||||
</div>
|
||||
<select id="domains-select-banned" name="banned" class="table-input ban" hx-on:change="resetTableNav()">
|
||||
<option value="" selected>Banned...</option>
|
||||
<option value="true">Banned</option>
|
||||
<option value="false">Not banned</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<select id="domains-select-links" name="links" class="table-input links" hx-on:change="resetTableNav()">
|
||||
<option value="" selected>Links...</option>
|
||||
<option value="true" {{#ifEquals query.links 'true'}}selected{{/ifEquals}}>With links</option>
|
||||
<option value="false" {{#ifEquals query.links 'true'}}selected{{/ifEquals}}>No links</option>
|
||||
</select>
|
||||
<select id="domains-select-owner" name="owner" class="table-input owner" hx-on:change="resetTableNav()">
|
||||
<option value="" selected>Owner...</option>
|
||||
<option value="true" {{#ifEquals query.owner 'true'}}selected{{/ifEquals}}>With owner</option>
|
||||
<option value="false" {{#ifEquals query.owner 'true'}}selected{{/ifEquals}}>No owner</option>
|
||||
</select>
|
||||
<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" />
|
||||
<button
|
||||
class="table primary"
|
||||
hx-on:click='openDialog("admin-table-dialog")'
|
||||
hx-get="/add-domain"
|
||||
hx-target="#admin-table-dialog .content-wrapper"
|
||||
hx-indicator="#admin-table-dialog"
|
||||
>
|
||||
<span>{{> icons/plus}}</span>
|
||||
Add domain
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
{{> admin/table_nav}}
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="domains-id">ID</th>
|
||||
<th class="domains-address">Address</th>
|
||||
<th class="domains-homepage">Homepage</th>
|
||||
<th class="domains-created-at">Created at</th>
|
||||
<th class="domains-links-count">Total links</th>
|
||||
<th class="domains-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
93
server/views/partials/admin/domains/tr.hbs
Normal file
93
server/views/partials/admin/domains/tr.hbs
Normal file
@ -0,0 +1,93 @@
|
||||
<tr id="tr-{{id}}" {{#if swap_oob}}hx-swap-oob="true"{{/if}}>
|
||||
<td class="domains-id">
|
||||
{{id}}
|
||||
</td>
|
||||
<td class="domains-address right-fade">
|
||||
{{address}}
|
||||
<p class="description">
|
||||
by
|
||||
{{~#if user_id~}}
|
||||
<a
|
||||
aria-label="View user"
|
||||
data-tooltip="View user"
|
||||
hx-get="/api/users/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:replace"
|
||||
hx-indicator="closest table"
|
||||
hx-vals='{"search":"{{email}}"}'
|
||||
onclick="setTab(event, 'tab-links')"
|
||||
>
|
||||
{{email}}
|
||||
</a>
|
||||
{{#ifEquals @root.query.user email}}
|
||||
{{else}}
|
||||
(
|
||||
<a
|
||||
aria-label="View domains"
|
||||
data-tooltip="View domains"
|
||||
hx-get="/api/domains/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:replace"
|
||||
hx-indicator="closest table"
|
||||
hx-vals='{"user":"{{email}}"}'
|
||||
>
|
||||
view domains
|
||||
</a>)
|
||||
{{/ifEquals}}
|
||||
{{~else~}}
|
||||
<a
|
||||
aria-label="View system domains"
|
||||
data-tooltip="View system domains"
|
||||
hx-get="/api/domains/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:replace"
|
||||
hx-indicator="closest table"
|
||||
hx-vals='{"owner":"false"}'
|
||||
>
|
||||
System
|
||||
</a>
|
||||
{{~/if~}}
|
||||
{{~#if description~}}· {{description}}{{~/if}}
|
||||
</p>
|
||||
</td>
|
||||
<td class="domains-homepage right-fade">
|
||||
{{#if homepage}}
|
||||
<a href="{{homepage}}" target="_blank" rel="noopener noreferrer">
|
||||
{{homepage}}
|
||||
</a>
|
||||
{{else}}
|
||||
No homepage
|
||||
{{/if}}
|
||||
</td>
|
||||
<td class="domains-created-at">
|
||||
{{relative_created_at}}
|
||||
</td>
|
||||
<td class="domains-links-count">
|
||||
{{#ifEquals links_count '0'}}
|
||||
{{links_count}}
|
||||
{{else}}
|
||||
<a
|
||||
data-tooltip="View links"
|
||||
aria-label="View links"
|
||||
hx-get="/api/links/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:replace"
|
||||
hx-vals='{"domain":"{{address}}"}'
|
||||
hx-indicator="closest table"
|
||||
onclick="setTab(event, 'tab-links')"
|
||||
>
|
||||
{{links_count}}
|
||||
</a>
|
||||
{{/ifEquals}}
|
||||
</td>
|
||||
{{> admin/domains/actions}}
|
||||
</tr>
|
||||
<tr class="edit">
|
||||
<td class="loading">
|
||||
{{> icons/spinner}}
|
||||
</td>
|
||||
</tr>
|
5
server/views/partials/admin/index.hbs
Normal file
5
server/views/partials/admin/index.hbs
Normal file
@ -0,0 +1,5 @@
|
||||
<section id="main-table-wrapper" class="admin-table-wrapper">
|
||||
<h2 id="admin-table-title">Recent shortened links.</h2>
|
||||
{{> admin/links/table onload=true}}
|
||||
{{> admin/dialog/frame}}
|
||||
</section>
|
71
server/views/partials/admin/links/actions.hbs
Normal file
71
server/views/partials/admin/links/actions.hbs
Normal file
@ -0,0 +1,71 @@
|
||||
<td class="actions">
|
||||
{{#if password}}
|
||||
<button class="action password" disabled="true" data-tooltip="Password protected">
|
||||
{{> icons/key}}
|
||||
</button>
|
||||
{{/if}}
|
||||
{{#if banned}}
|
||||
<button class="action banned" disabled="true" data-tooltip="Banned">
|
||||
{{> icons/stop}}
|
||||
</button>
|
||||
{{/if}}
|
||||
<a
|
||||
class="button action stats"
|
||||
href="/stats?id={{id}}"
|
||||
title="Stats"
|
||||
class="action stats"
|
||||
>
|
||||
{{> icons/chart}}
|
||||
</a>
|
||||
<button
|
||||
class="action qrcode"
|
||||
hx-on:click="handleQRCode(this, 'admin-table-dialog')"
|
||||
data-url="{{link.url}}"
|
||||
>
|
||||
{{> icons/qrcode}}
|
||||
</button>
|
||||
<button
|
||||
class="action edit"
|
||||
hx-trigger="click queue:none"
|
||||
hx-ext="path-params"
|
||||
hx-get="/admin/link/edit/{id}"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
hx-swap="beforeend"
|
||||
hx-target="next tr.edit"
|
||||
hx-indicator="next tr.edit"
|
||||
hx-sync="this:drop"
|
||||
hx-on::before-request="
|
||||
const tr = event.detail.target;
|
||||
tr.classList.add('show');
|
||||
if (tr.querySelector('.content')) {
|
||||
event.preventDefault();
|
||||
tr.classList.remove('show');
|
||||
tr.removeChild(tr.querySelector('.content'));
|
||||
}
|
||||
"
|
||||
>
|
||||
{{> icons/pencil}}
|
||||
</button>
|
||||
{{#unless banned}}
|
||||
<button
|
||||
class="action ban"
|
||||
hx-on:click='openDialog("admin-table-dialog")'
|
||||
hx-get="/confirm-link-ban"
|
||||
hx-target="#admin-table-dialog .content-wrapper"
|
||||
hx-indicator="#admin-table-dialog"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
>
|
||||
{{> icons/stop}}
|
||||
</button>
|
||||
{{/unless}}
|
||||
<button
|
||||
class="action delete"
|
||||
hx-on:click='openDialog("admin-table-dialog")'
|
||||
hx-get="/confirm-link-delete"
|
||||
hx-target="#admin-table-dialog .content-wrapper"
|
||||
hx-indicator="#admin-table-dialog"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
>
|
||||
{{> icons/trash}}
|
||||
</button>
|
||||
</td>
|
117
server/views/partials/admin/links/edit.hbs
Normal file
117
server/views/partials/admin/links/edit.hbs
Normal file
@ -0,0 +1,117 @@
|
||||
<td class="content">
|
||||
{{#if id}}
|
||||
<form
|
||||
id="edit-form-{{id}}"
|
||||
hx-patch="/api/links/admin/{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
|
||||
type="button"
|
||||
onclick="
|
||||
const tr = closest('tr');
|
||||
if (!tr) return;
|
||||
tr.classList.remove('show');
|
||||
tr.removeChild(tr.querySelector('.content'));
|
||||
"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" class="primary">
|
||||
<span class="reload">
|
||||
{{> icons/reload}}
|
||||
</span>
|
||||
<span class="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>
|
||||
{{> admin/links/tr}}
|
||||
</template>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="no-data">No link was found.</p>
|
||||
{{/if}}
|
||||
</td>
|
16
server/views/partials/admin/links/loading.hbs
Normal file
16
server/views/partials/admin/links/loading.hbs
Normal file
@ -0,0 +1,16 @@
|
||||
{{#unless links}}
|
||||
{{#ifEquals links.length 0}}
|
||||
<tr class="no-data">
|
||||
<td>
|
||||
No links.
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr class="loading-placeholder">
|
||||
<td>
|
||||
{{> icons/spinner}}
|
||||
Loading links...
|
||||
</td>
|
||||
</tr>
|
||||
{{/ifEquals}}
|
||||
{{/unless}}
|
31
server/views/partials/admin/links/table.hbs
Normal file
31
server/views/partials/admin/links/table.hbs
Normal file
@ -0,0 +1,31 @@
|
||||
<table
|
||||
hx-get="/api/links/admin"
|
||||
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,#category-total"
|
||||
hx-trigger="
|
||||
{{#if onload}}load once,{{/if}}
|
||||
reloadMainTable from:body,
|
||||
click delay:100ms from:button.nav,
|
||||
input changed delay:500ms from:[name='search'],
|
||||
input changed delay:500ms from:[name='user'],
|
||||
input changed delay:500ms from:[name='domain'],
|
||||
input changed from:[name='banned'],
|
||||
input changed from:[name='anonymous'],
|
||||
input changed from:[name='has_domain'],
|
||||
"
|
||||
hx-on:htmx:after-on-load="updateLinksNav();"
|
||||
hx-on:htmx:after-settle="onSearchInputLoad();"
|
||||
>
|
||||
{{> admin/links/thead}}
|
||||
{{> admin/links/tbody}}
|
||||
{{> admin/links/tfoot}}
|
||||
</table>
|
||||
<template>
|
||||
<h2 id="admin-table-title" hx-swap-oob="true">Recent shortened links.</h2>
|
||||
</template>
|
6
server/views/partials/admin/links/tbody.hbs
Normal file
6
server/views/partials/admin/links/tbody.hbs
Normal file
@ -0,0 +1,6 @@
|
||||
<tbody>
|
||||
{{> admin/links/loading}}
|
||||
{{#each links}}
|
||||
{{> admin/links/tr}}
|
||||
{{/each}}
|
||||
</tbody>
|
5
server/views/partials/admin/links/tfoot.hbs
Normal file
5
server/views/partials/admin/links/tfoot.hbs
Normal file
@ -0,0 +1,5 @@
|
||||
<tfoot>
|
||||
<tr class="controls links-controls">
|
||||
{{> admin/table_nav}}
|
||||
</tr>
|
||||
</tfoot>
|
112
server/views/partials/admin/links/thead.hbs
Normal file
112
server/views/partials/admin/links/thead.hbs
Normal file
@ -0,0 +1,112 @@
|
||||
<thead>
|
||||
{{> admin/table_tab title='links'}}
|
||||
<tr class="controls links-controls with-filters">
|
||||
<th class="filters">
|
||||
<div>
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
id="search"
|
||||
name="search"
|
||||
type="text"
|
||||
placeholder="Search link..."
|
||||
class="table-input search admin"
|
||||
hx-on:input="onSearchChange(event)"
|
||||
hx-on:keyup="resetTableNav()"
|
||||
value="{{query.search}}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
class="clear"
|
||||
onclick="clearSeachInput(event)"
|
||||
>
|
||||
{{> icons/x}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
id="search_domain"
|
||||
name="domain"
|
||||
type="text"
|
||||
placeholder="Search domain..."
|
||||
class="table-input search admin"
|
||||
hx-on:input="onSearchChange(event)"
|
||||
hx-on:keyup="resetTableNav()"
|
||||
value="{{query.domain}}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear user search"
|
||||
class="clear"
|
||||
onclick="clearSeachInput(event)"
|
||||
>
|
||||
{{> icons/x}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
id="search_user"
|
||||
name="user"
|
||||
type="text"
|
||||
placeholder="Search user..."
|
||||
class="table-input search admin"
|
||||
hx-on:input="onSearchChange(event)"
|
||||
hx-on:keyup="resetTableNav()"
|
||||
value="{{query.user}}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear user search"
|
||||
class="clear"
|
||||
onclick="clearSeachInput(event)"
|
||||
>
|
||||
{{> icons/x}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
id="links-select-banned"
|
||||
name="banned"
|
||||
class="table-input ban"
|
||||
hx-on:change="resetTableNav()"
|
||||
>
|
||||
<option value="" selected>Banned...</option>
|
||||
<option value="true">Banned</option>
|
||||
<option value="false">Not banned</option>
|
||||
</select>
|
||||
<select
|
||||
id="links-select-anonymous"
|
||||
name="anonymous"
|
||||
class="table-input anonymous"
|
||||
hx-on:change="resetTableNav()"
|
||||
>
|
||||
<option value="">Anonymous...</option>
|
||||
<option value="true" {{#ifEquals query.anonymous 'true'}}selected{{/ifEquals}}>Anonymous</option>
|
||||
<option value="false" {{#ifEquals query.anonymous 'false'}}selected{{/ifEquals}}>User</option>
|
||||
</select>
|
||||
<select
|
||||
id="links-select-anonymous"
|
||||
name="has_domain"
|
||||
class="table-input has_domain"
|
||||
hx-on:change="resetTableNav()"
|
||||
>
|
||||
<option value="">Domain...</option>
|
||||
<option value="true" {{#ifEquals query.has_domain 'true'}}selected{{/ifEquals}}>With domain</option>
|
||||
<option value="false" {{#ifEquals query.has_domain 'false'}}selected{{/ifEquals}}>No domain</option>
|
||||
</select>
|
||||
<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" />
|
||||
</div>
|
||||
</th>
|
||||
{{> admin/table_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>
|
99
server/views/partials/admin/links/tr.hbs
Normal file
99
server/views/partials/admin/links/tr.hbs
Normal file
@ -0,0 +1,99 @@
|
||||
<tr id="tr-{{id}}" {{#if swap_oob}}hx-swap-oob="true"{{/if}}>
|
||||
<td class="original-url right-fade">
|
||||
<a href="{{target}}" target="_blank" rel="noopener noreferrer">
|
||||
{{target}}
|
||||
</a>
|
||||
<p class="description">
|
||||
by
|
||||
{{~#if user_id~}}
|
||||
<a
|
||||
aria-label="View user"
|
||||
data-tooltip="View user"
|
||||
hx-get="/api/users/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:replace"
|
||||
hx-indicator="closest table"
|
||||
hx-vals='{"search":"{{email}}"}'
|
||||
onclick="setTab(event, 'tab-links')"
|
||||
>
|
||||
{{email}}
|
||||
</a>
|
||||
{{#ifEquals @root.query.user email}}
|
||||
{{else}}
|
||||
(
|
||||
<a
|
||||
aria-label="View links by this user"
|
||||
data-tooltip="View links by this user"
|
||||
hx-get="/api/links/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:replace"
|
||||
hx-indicator="closest table"
|
||||
hx-vals='{"user":"{{email}}"}'
|
||||
>
|
||||
view links
|
||||
</a>)
|
||||
{{/ifEquals}}
|
||||
{{~else~}}
|
||||
<a
|
||||
aria-label="View anonymous links"
|
||||
data-tooltip="View anonymous links"
|
||||
hx-get="/api/links/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:replace"
|
||||
hx-indicator="closest table"
|
||||
hx-vals='{"anonymous":"true"}'
|
||||
>
|
||||
Anonymous
|
||||
</a>
|
||||
{{~/if~}}
|
||||
{{~#if description~}}· {{description}}{{~/if}}
|
||||
</p>
|
||||
</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 right-fade">
|
||||
<div class="short-link-wrapper">
|
||||
<div class="clipboard small">
|
||||
<button
|
||||
aria-label="Copy"
|
||||
hx-on:click="handleShortURLCopyLink(this);"
|
||||
data-url="{{link.url}}"
|
||||
>
|
||||
{{> icons/copy}}
|
||||
</button>
|
||||
{{> icons/check}}
|
||||
</div>
|
||||
<a href="{{link.url}}">/{{link.address}}</a>
|
||||
</div>
|
||||
<p class="description">
|
||||
<a
|
||||
aria-label="View links by this domain"
|
||||
data-tooltip="View links by this domain"
|
||||
hx-get="/api/links/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:replace"
|
||||
hx-vals='{"domain":"{{domain}}"}'
|
||||
hx-indicator="closest table"
|
||||
>{{domain}}</a>
|
||||
</p>
|
||||
</td>
|
||||
<td class="views">
|
||||
{{visit_count}}
|
||||
</td>
|
||||
{{> admin/links/actions}}
|
||||
</tr>
|
||||
<tr class="edit">
|
||||
<td class="loading">
|
||||
{{> icons/spinner}}
|
||||
</td>
|
||||
</tr>
|
16
server/views/partials/admin/table_nav.hbs
Normal file
16
server/views/partials/admin/table_nav.hbs
Normal file
@ -0,0 +1,16 @@
|
||||
<th class="nav" >
|
||||
<div class="limit">
|
||||
<button type="button" class="nav" onclick="setLinksLimit(event)" disabled="true">10</button>
|
||||
<button type="button" class="nav" onclick="setLinksLimit(event)">20</button>
|
||||
<button type="button" class="nav" onclick="setLinksLimit(event)">50</button>
|
||||
</div>
|
||||
<div class="nav-divider"></div>
|
||||
<div id="pagination" class="pagination">
|
||||
<button type="button" class="nav prev" onclick="setLinksSkip(event, 'prev')" disabled="true">
|
||||
{{> icons/chevron_left}}
|
||||
</button>
|
||||
<button type="button" class="nav next" onclick="setLinksSkip(event, 'next')">
|
||||
{{> icons/chevron_right}}
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
62
server/views/partials/admin/table_tab.hbs
Normal file
62
server/views/partials/admin/table_tab.hbs
Normal file
@ -0,0 +1,62 @@
|
||||
<tr class="category">
|
||||
<th class="category-total">
|
||||
<p id="category-total">
|
||||
Total {{title}}: <b>{{#if total includeZero=true}}{{total_formatted}}{{else}}-{{/if}}</b>
|
||||
</p>
|
||||
</th>
|
||||
<th class="category-tab">
|
||||
<nav class="tab" role="tablist">
|
||||
<a
|
||||
id="tab-links"
|
||||
role="tab"
|
||||
hx-get="/api/links/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-disinherit="*"
|
||||
hx-sync="this:replace"
|
||||
hx-indicator="closest table"
|
||||
onclick="setTab(event)"
|
||||
{{#ifEquals title 'links'}}
|
||||
class="active"
|
||||
hx-on:htmx:before-request="event.preventDefault()"
|
||||
{{/ifEquals}}
|
||||
>
|
||||
Links
|
||||
</a>
|
||||
<a
|
||||
id="tab-users"
|
||||
role="tab"
|
||||
hx-get="/api/users/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-disinherit="*"
|
||||
hx-sync="this:replace"
|
||||
hx-indicator="closest table"
|
||||
onclick="setTab(event)"
|
||||
{{#ifEquals title 'users'}}
|
||||
class="active"
|
||||
hx-on:htmx:before-request="event.preventDefault()"
|
||||
{{/ifEquals}}
|
||||
>
|
||||
Users
|
||||
</a>
|
||||
<a
|
||||
id="tab-domains"
|
||||
role="tab"
|
||||
hx-get="/api/domains/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-disinherit="*"
|
||||
hx-sync="this:replace"
|
||||
hx-indicator="closest table"
|
||||
onclick="setTab(event)"
|
||||
{{#ifEquals title 'domains'}}
|
||||
class="active"
|
||||
hx-on:htmx:before-request="event.preventDefault()"
|
||||
{{/ifEquals}}
|
||||
>
|
||||
Domains
|
||||
</a>
|
||||
</nav>
|
||||
</th>
|
||||
</tr>
|
29
server/views/partials/admin/users/actions.hbs
Normal file
29
server/views/partials/admin/users/actions.hbs
Normal file
@ -0,0 +1,29 @@
|
||||
<td class="actions users-actions">
|
||||
{{#if banned}}
|
||||
<button class="action banned" disabled="true" data-tooltip="Banned">
|
||||
{{> icons/stop}}
|
||||
</button>
|
||||
{{/if}}
|
||||
{{#unless banned}}
|
||||
<button
|
||||
class="action ban"
|
||||
hx-on:click='openDialog("admin-table-dialog")'
|
||||
hx-get="/confirm-user-ban"
|
||||
hx-target="#admin-table-dialog .content-wrapper"
|
||||
hx-indicator="#admin-table-dialog"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
>
|
||||
{{> icons/stop}}
|
||||
</button>
|
||||
{{/unless}}
|
||||
<button
|
||||
class="action delete"
|
||||
hx-on:click='openDialog("admin-table-dialog")'
|
||||
hx-get="/confirm-user-delete"
|
||||
hx-target="#admin-table-dialog .content-wrapper"
|
||||
hx-indicator="#admin-table-dialog"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
>
|
||||
{{> icons/trash}}
|
||||
</button>
|
||||
</td>
|
16
server/views/partials/admin/users/loading.hbs
Normal file
16
server/views/partials/admin/users/loading.hbs
Normal file
@ -0,0 +1,16 @@
|
||||
{{#unless users}}
|
||||
{{#ifEquals users.length 0}}
|
||||
<tr class="no-data">
|
||||
<td>
|
||||
No users.
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr class="loading-placeholder">
|
||||
<td>
|
||||
{{> icons/spinner}}
|
||||
Loading users...
|
||||
</td>
|
||||
</tr>
|
||||
{{/ifEquals}}
|
||||
{{/unless}}
|
31
server/views/partials/admin/users/table.hbs
Normal file
31
server/views/partials/admin/users/table.hbs
Normal file
@ -0,0 +1,31 @@
|
||||
<table
|
||||
hx-get="/api/users/admin"
|
||||
hx-target="tbody"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="tbody"
|
||||
hx-disinherit="*"
|
||||
hx-include=".users-controls"
|
||||
hx-params="not total"
|
||||
hx-sync="this:replace"
|
||||
hx-select-oob="#total,#category-total"
|
||||
hx-trigger="
|
||||
{{#if onload}}load once,{{/if}}
|
||||
reloadMainTable from:body,
|
||||
click delay:100ms from:button.nav,
|
||||
input changed delay:500ms from:[name='search'],
|
||||
input changed from:[name='verified'],
|
||||
input changed from:[name='banned'],
|
||||
input changed from:[name='role'],
|
||||
input changed from:[name='domains'],
|
||||
input changed from:[name='links'],
|
||||
"
|
||||
hx-on:htmx:after-on-load="updateLinksNav()"
|
||||
hx-on:htmx:after-settle="onSearchInputLoad();"
|
||||
>
|
||||
{{> admin/users/thead}}
|
||||
{{> admin/users/tbody}}
|
||||
{{> admin/users/tfoot}}
|
||||
</table>
|
||||
<template>
|
||||
<h2 id="admin-table-title" hx-swap-oob="true">Recent created users.</h2>
|
||||
</template>
|
6
server/views/partials/admin/users/tbody.hbs
Normal file
6
server/views/partials/admin/users/tbody.hbs
Normal file
@ -0,0 +1,6 @@
|
||||
<tbody>
|
||||
{{> admin/users/loading}}
|
||||
{{#each users}}
|
||||
{{> admin/users/tr}}
|
||||
{{/each}}
|
||||
</tbody>
|
5
server/views/partials/admin/users/tfoot.hbs
Normal file
5
server/views/partials/admin/users/tfoot.hbs
Normal file
@ -0,0 +1,5 @@
|
||||
<tfoot>
|
||||
<tr class="controls users-controls">
|
||||
{{> admin/table_nav}}
|
||||
</tr>
|
||||
</tfoot>
|
79
server/views/partials/admin/users/thead.hbs
Normal file
79
server/views/partials/admin/users/thead.hbs
Normal file
@ -0,0 +1,79 @@
|
||||
<thead>
|
||||
{{> admin/table_tab title='users'}}
|
||||
<tr class="controls users-controls with-filters">
|
||||
<th class="filters">
|
||||
<div>
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
id="search"
|
||||
name="search"
|
||||
type="text"
|
||||
placeholder="Search user..."
|
||||
class="table-input search admin"
|
||||
hx-on:input="onSearchChange(event)"
|
||||
hx-on:keyup="resetTableNav()"
|
||||
value="{{query.search}}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
class="clear"
|
||||
onclick="clearSeachInput(event)"
|
||||
>
|
||||
{{> icons/x}}
|
||||
</button>
|
||||
</div>
|
||||
<select id="users-select-verified" name="verified" class="table-input verification" hx-on:change="resetTableNav()">
|
||||
<option value="">Verification...</option>
|
||||
<option value="true" {{#ifEquals query.verified 'true'}}selected{{/ifEquals}}>Verified</option>
|
||||
<option value="false" {{#ifEquals query.verified 'false'}}selected{{/ifEquals}}>Not verified</option>
|
||||
</select>
|
||||
<select id="users-select-banned" name="banned" class="table-input ban" hx-on:change="resetTableNav()">
|
||||
<option value="" selected>Banned...</option>
|
||||
<option value="true">Banned</option>
|
||||
<option value="false">Not banned</option>
|
||||
</select>
|
||||
<select id="users-select-role" name="role" class="table-input role" hx-on:change="resetTableNav()">
|
||||
<option value="">Role...</option>
|
||||
<option value="USER" {{#ifEquals query.role 'USER'}}selected{{/ifEquals}}>User</option>
|
||||
<option value="ADMIN" {{#ifEquals query.role 'ADMIN'}}selected{{/ifEquals}}>Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<select id="users-select-domain" name="domains" class="table-input domains" hx-on:change="resetTableNav()">
|
||||
<option value="">Domain...</option>
|
||||
<option value="true" {{#ifEquals query.domains 'true'}}selected{{/ifEquals}}>With domains</option>
|
||||
<option value="false" {{#ifEquals query.domains 'false'}}selected{{/ifEquals}}>No domains</option>
|
||||
</select>
|
||||
<select id="users-select-links" name="links" class="table-input links" hx-on:change="resetTableNav()">
|
||||
<option value="" selected>Links...</option>
|
||||
<option value="true" {{#ifEquals query.links 'true'}}selected{{/ifEquals}}>With links</option>
|
||||
<option value="false" {{#ifEquals query.links 'true'}}selected{{/ifEquals}}>No links</option>
|
||||
</select>
|
||||
<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" />
|
||||
<button
|
||||
class="table primary"
|
||||
hx-on:click='openDialog("admin-table-dialog")'
|
||||
hx-get="/create-user"
|
||||
hx-target="#admin-table-dialog .content-wrapper"
|
||||
hx-indicator="#admin-table-dialog"
|
||||
>
|
||||
<span>{{> icons/new_user}}</span>
|
||||
Create user
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
{{> admin/table_nav}}
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="users-id">ID</th>
|
||||
<th class="users-email">Email</th>
|
||||
<th class="users-created-at">Created at</th>
|
||||
<th class="users-verified">Verified</th>
|
||||
<th class="users-role">Role</th>
|
||||
<th class="users-links-count">Total links</th>
|
||||
<th class="users-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
69
server/views/partials/admin/users/tr.hbs
Normal file
69
server/views/partials/admin/users/tr.hbs
Normal file
@ -0,0 +1,69 @@
|
||||
<tr id="tr-{{id}}" {{#if swap_oob}}hx-swap-oob="true"{{/if}}>
|
||||
<td class="users-id">
|
||||
{{id}}
|
||||
</td>
|
||||
<td class="users-email">
|
||||
{{email}}
|
||||
<p class="description">
|
||||
{{#if domains}}
|
||||
<a
|
||||
aria-label="View domains"
|
||||
data-tooltip="View domains"
|
||||
hx-get="/api/domains/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:replace"
|
||||
hx-indicator="closest table"
|
||||
hx-vals='{"user":"{{email}}"}'
|
||||
onclick="setTab(event, 'tab-links')"
|
||||
>
|
||||
{{domains}}
|
||||
</a>
|
||||
{{else}}
|
||||
<span>No domains</span>
|
||||
{{/if}}
|
||||
</p>
|
||||
</td>
|
||||
<td class="users-created-at">
|
||||
{{relative_created_at}}
|
||||
</td>
|
||||
<td class="users-verified">
|
||||
{{#if verified}}
|
||||
<span class="status green">VERIFIED</span>
|
||||
{{else}}
|
||||
<span class="status gray">NOT VERIFIED</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td class="users-role">
|
||||
{{#ifEquals role "ADMIN"}}
|
||||
<span class="status red">ADMIN</span>
|
||||
{{else}}
|
||||
<span class="status gray">USER</span>
|
||||
{{/ifEquals}}
|
||||
</td>
|
||||
<td class="users-links-count">
|
||||
{{#ifEquals links_count '0'}}
|
||||
{{links_count}}
|
||||
{{else}}
|
||||
<a
|
||||
data-tooltip="View links"
|
||||
aria-label="View links"
|
||||
hx-get="/api/links/admin"
|
||||
hx-target="closest table"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:replace"
|
||||
hx-vals='{"user":"{{email}}"}'
|
||||
hx-indicator="closest table"
|
||||
onclick="setTab(event, 'tab-links')"
|
||||
>
|
||||
{{links_count}}
|
||||
</a>
|
||||
{{/ifEquals}}
|
||||
</td>
|
||||
{{> admin/users/actions}}
|
||||
</tr>
|
||||
<tr class="edit">
|
||||
<td class="loading">
|
||||
{{> icons/spinner}}
|
||||
</td>
|
||||
</tr>
|
@ -11,7 +11,7 @@
|
||||
/>
|
||||
{{#if errors.email}}<p class="error">{{errors.email}}</p>{{/if}}
|
||||
</label>
|
||||
<label class="{{#if errors.password}}error{{/if}}">
|
||||
<label class="{{#if errors.password}}error{{/if}}">
|
||||
Password:
|
||||
<input
|
||||
name="password"
|
||||
|
@ -29,9 +29,18 @@
|
||||
{{#if user}}
|
||||
<li>
|
||||
<a class="button primary" href="/settings" title="Settings">
|
||||
<span>{{> icons/cog}}</span>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
{{#if isAdmin}}
|
||||
<li>
|
||||
<a class="button secondary" href="/admin" title="Admin">
|
||||
<span>{{> icons/shield}}</span>
|
||||
Admin
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
<li>
|
||||
<a class="nav" href="/logout" title="Log out">
|
||||
Log out
|
||||
|
1
server/views/partials/icons/cog.hbs
Normal file
1
server/views/partials/icons/cog.hbs
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="prefix__feather prefix__feather-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.7 1.7 0 0 0-1.82-.33 1.7 1.7 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.7 1.7 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.7 1.7 0 0 0 .33-1.82 1.7 1.7 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.7 1.7 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.7 1.7 0 0 0 1.82.33H9a1.7 1.7 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.7 1.7 0 0 0 1 1.51 1.7 1.7 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.7 1.7 0 0 0-.33 1.82V9a1.7 1.7 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.7 1.7 0 0 0-1.51 1"/></svg>
|
After Width: | Height: | Size: 961 B |
@ -1 +1 @@
|
||||
<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 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>
|
Before Width: | Height: | Size: 225 B After Width: | Height: | Size: 202 B |
1
server/views/partials/icons/shield.hbs
Normal file
1
server/views/partials/icons/shield.hbs
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="prefix__feather prefix__feather-shield" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/></svg>
|
After Width: | Height: | Size: 265 B |
@ -19,7 +19,7 @@
|
||||
</a>
|
||||
<button
|
||||
class="action qrcode"
|
||||
hx-on:click="handleQRCode(this)"
|
||||
hx-on:click="handleQRCode(this, 'link-dialog')"
|
||||
data-url="{{link.url}}"
|
||||
>
|
||||
{{> icons/qrcode}}
|
||||
@ -46,20 +46,6 @@
|
||||
>
|
||||
{{> icons/pencil}}
|
||||
</button>
|
||||
{{#unless banned}}
|
||||
{{#if @root.isAdmin}}
|
||||
<button
|
||||
class="action ban"
|
||||
hx-on:click='openDialog("link-dialog")'
|
||||
hx-get="/confirm-link-ban"
|
||||
hx-target="#link-dialog .content-wrapper"
|
||||
hx-indicator="#link-dialog"
|
||||
hx-vals='{"id":"{{id}}"}'
|
||||
>
|
||||
{{> icons/stop}}
|
||||
</button>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
<button
|
||||
class="action delete"
|
||||
hx-on:click='openDialog("link-dialog")'
|
||||
|
@ -112,6 +112,6 @@
|
||||
</template>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="no-links">No link was found.</p>
|
||||
<p class="no-data">No link was found.</p>
|
||||
{{/if}}
|
||||
</td>
|
@ -1,6 +1,6 @@
|
||||
{{#unless links}}
|
||||
{{#ifEquals links.length 0}}
|
||||
<tr class="no-links">
|
||||
<tr class="no-data">
|
||||
<td>
|
||||
No links.
|
||||
</td>
|
||||
|
@ -1,4 +1,4 @@
|
||||
<section id="links-table-wrapper">
|
||||
<section id="main-table-wrapper">
|
||||
<h2>Recent shortened links.</h2>
|
||||
<table
|
||||
hx-get="/api/links"
|
||||
@ -12,8 +12,7 @@
|
||||
hx-select-oob="#total"
|
||||
hx-trigger="
|
||||
load once,
|
||||
reloadLinks from:body,
|
||||
change from:[name='all'],
|
||||
reloadMainTable from:body,
|
||||
click delay:100ms from:button.nav,
|
||||
input changed delay:500ms from:[name='search'],
|
||||
"
|
||||
|
@ -1,5 +1,5 @@
|
||||
<tfoot>
|
||||
<tr class="links-controls">
|
||||
<tr class="controls links-controls">
|
||||
{{> links/nav}}
|
||||
</tr>
|
||||
</tfoot>
|
@ -1,16 +1,10 @@
|
||||
<thead>
|
||||
<tr class="links-controls">
|
||||
<tr class="controls links-controls">
|
||||
<th class="search">
|
||||
<input id="search" name="search" type="text" placeholder="Search..." hx-on:keyup="resetLinkNav()" />
|
||||
<input class="table-input search" id="search" name="search" type="text" placeholder="Search..." hx-on:keyup="resetTableNav()" />
|
||||
<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" />
|
||||
{{#if @root.isAdmin}}
|
||||
<label id="all" class="checkbox">
|
||||
<input name="all" type="checkbox" />
|
||||
All links
|
||||
</label>
|
||||
{{/if}}
|
||||
</th>
|
||||
{{> links/nav}}
|
||||
</tr>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<tr id="tr-{{id}}" {{#if swap_oob}}hx-swap-oob="true"{{/if}}>
|
||||
<td class="original-url right-fade">
|
||||
<a href="{{target}}">
|
||||
<a href="{{target}}" target="_blank" rel="noopener noreferrer">
|
||||
{{target}}
|
||||
</a>
|
||||
{{#if description}}
|
||||
@ -28,7 +28,9 @@
|
||||
</button>
|
||||
{{> icons/check}}
|
||||
</div>
|
||||
<a href="{{link.url}}">{{link.link}}</a>
|
||||
<a href="{{link.url}}" target="_blank" rel="noopener noreferrer">
|
||||
{{link.link}}
|
||||
</a>
|
||||
</td>
|
||||
<td class="views">
|
||||
{{visit_count}}
|
||||
|
@ -86,12 +86,24 @@ hr {
|
||||
span.bold { font-weight: bold; }
|
||||
span.underline { border-bottom: 2px dotted #999; }
|
||||
|
||||
.space-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
a,
|
||||
button.link {
|
||||
color: var(--color-primary);
|
||||
border-bottom: 1px dotted transparent;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
@ -99,6 +111,10 @@ button.link:hover {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
a.wrapper-only {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a.nav {
|
||||
color: inherit;
|
||||
padding-bottom: 2px;
|
||||
@ -205,7 +221,7 @@ a.button svg.with-text,
|
||||
a.button span svg,
|
||||
button svg.with-text,
|
||||
button span svg {
|
||||
width: 16px;
|
||||
width: 1.1em;
|
||||
height: auto;
|
||||
margin-right: 0.5rem;
|
||||
stroke: white;
|
||||
@ -332,6 +348,44 @@ button.nav svg { stroke-width: 2.5; }
|
||||
button.nav:hover { transform: translateY(-2px); }
|
||||
button.nav:disabled:hover { transform: none; }
|
||||
|
||||
button.table {
|
||||
height: 32px;
|
||||
padding: 0 1rem;
|
||||
font-size: 12px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
box-shadow: 0 1px 2px var(--button-bg-box-shadow-color);
|
||||
}
|
||||
|
||||
button.table:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 1px 2px var(--button-bg-box-shadow-color);
|
||||
}
|
||||
|
||||
button.table.primary,
|
||||
button.primary:focus,
|
||||
button.primary:hover {
|
||||
box-shadow: 0 1px 2px var(--button-bg-primary-box-shadow-color);
|
||||
}
|
||||
|
||||
button.table.secondary,
|
||||
button.secondary:focus,
|
||||
button.secondary:hover {
|
||||
box-shadow: 0 1px 2px var(--button-bg-secondary-box-shadow-color);
|
||||
}
|
||||
|
||||
button.table.danger,
|
||||
button.danger:focus,
|
||||
button.danger:hover {
|
||||
box-shadow: 0 1px 2px var(--button-bg-danger-box-shadow-color);
|
||||
}
|
||||
|
||||
button.table.success,
|
||||
button.success:focus,
|
||||
button.success:hover {
|
||||
box-shadow: 0 1px 2px var(--button-bg-success-box-shadow-color);
|
||||
}
|
||||
|
||||
button.link {
|
||||
position: relative;
|
||||
width: auto;
|
||||
@ -489,6 +543,29 @@ input[type="checkbox"]:checked:after {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
input.table-input,
|
||||
select.table-input {
|
||||
width: auto;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
padding: 0 1.5rem;
|
||||
border-radius: 3px;
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
|
||||
select.table-input {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
input.table-input::placeholder {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
select:has(option[value=""]:checked) {
|
||||
letter-spacing: 0.05em;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
color: rgb(41, 71, 86);
|
||||
@ -616,6 +693,48 @@ table tr.loading-placeholder td {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
table select {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
table .tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
table .tab a {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem 1rem;
|
||||
margin: 0 0.5rem;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1);
|
||||
font-weight: normal;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
table .tab a:first-child { margin-left: 0}
|
||||
|
||||
table .tab a.active {
|
||||
background-color: #f6f6f6;
|
||||
box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
|
||||
color: #aaa;
|
||||
font-weight: bold;
|
||||
opacity: 0.9;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
table .tab a:not(.active):hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
@ -746,10 +865,53 @@ table tr.loading-placeholder td {
|
||||
.dialog .content.htmx-request svg.spinner { display: block; }
|
||||
.dialog .content.htmx-request button { display: none; }
|
||||
|
||||
.dialog .content label { margin: 0.5rem 0; }
|
||||
|
||||
.dialog .content input[type="text"],
|
||||
.dialog .content input[type="password"],
|
||||
.dialog .content input[type="email"],
|
||||
.dialog .content select {
|
||||
width: 320px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.inputs { display: flex; align-items: flex-start; margin-bottom: 1rem; }
|
||||
.inputs label { flex: 0 0 0; margin-right: 1rem; }
|
||||
.inputs label:last-child { margin-right: 0; }
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input-wrapper button {
|
||||
position: absolute;
|
||||
display: none;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 3px;
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
margin-right: 0.25rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.search-input-wrapper button:hover {
|
||||
transform: translateY(-55%);
|
||||
}
|
||||
|
||||
.search-input-wrapper svg {
|
||||
width: 0.9rem;
|
||||
height: auto;
|
||||
stroke-width: 2;
|
||||
stroke: #888;
|
||||
}
|
||||
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
@ -1182,9 +1344,9 @@ main form label#advanced input {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* LINKS TABLE */
|
||||
/* MAIN TABLE */
|
||||
|
||||
#links-table-wrapper {
|
||||
#main-table-wrapper {
|
||||
width: 1200px;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
@ -1195,115 +1357,174 @@ main form label#advanced input {
|
||||
margin: 7rem 0 7.5rem;
|
||||
}
|
||||
|
||||
#links-table-wrapper h2 {
|
||||
#main-table-wrapper h2 {
|
||||
font-weight: 300;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#links-table-wrapper table thead,
|
||||
#links-table-wrapper table tbody,
|
||||
#links-table-wrapper table tfoot {
|
||||
#main-table-wrapper table thead,
|
||||
#main-table-wrapper table tbody,
|
||||
#main-table-wrapper table tfoot {
|
||||
min-width: 1000px;
|
||||
}
|
||||
|
||||
#links-table-wrapper tr {
|
||||
#main-table-wrapper tr {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
#links-table-wrapper th,
|
||||
#links-table-wrapper td {
|
||||
#main-table-wrapper th,
|
||||
#main-table-wrapper td {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
#links-table-wrapper td {
|
||||
#main-table-wrapper td {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
|
||||
#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; overflow: visible; }
|
||||
#links-table-wrapper table .actions a.button,
|
||||
#links-table-wrapper table .actions button { margin-right: 0.5rem; }
|
||||
#links-table-wrapper table .actions a.button:last-child,
|
||||
#links-table-wrapper table .actions button:last-child { margin-right: 0; }
|
||||
#main-table-wrapper table .original-url { flex: 7 7 0; }
|
||||
#main-table-wrapper table .created-at { flex: 2.5 2.5 0; }
|
||||
#main-table-wrapper table .short-link { flex: 3 3 0; }
|
||||
#main-table-wrapper.admin-table-wrapper table .short-link { overflow: visible; }
|
||||
#main-table-wrapper table .views { flex: 1 1 0; justify-content: flex-end; }
|
||||
#main-table-wrapper table .actions { flex: 3 3 0; justify-content: flex-end; overflow: visible; }
|
||||
#main-table-wrapper table .actions a.button,
|
||||
#main-table-wrapper table .actions button { margin-right: 0.5rem; }
|
||||
#main-table-wrapper table .actions a.button:last-child,
|
||||
#main-table-wrapper table .actions button:last-child { margin-right: 0; }
|
||||
|
||||
#links-table-wrapper table td.original-url,
|
||||
#links-table-wrapper table td.created-at {
|
||||
#main-table-wrapper table .users-id { flex: 3 3 0; justify-content: flex-end; }
|
||||
#main-table-wrapper table .users-email { flex: 9 9 0; }
|
||||
#main-table-wrapper table .users-created-at { flex: 4 4 0; }
|
||||
#main-table-wrapper table .users-updated-at { flex: 4 4 0; }
|
||||
#main-table-wrapper table .users-verified { flex: 3 3 0; overflow: visible; }
|
||||
#main-table-wrapper table .users-role { flex: 2 2 0; overflow: visible; }
|
||||
#main-table-wrapper table .users-links-count { flex: 3 3 0; justify-content: flex-end; overflow: visible; }
|
||||
#main-table-wrapper table .users-actions { flex: 2 2 0; }
|
||||
|
||||
#main-table-wrapper table .domains-id { flex: 2 2 0; justify-content: flex-end; }
|
||||
#main-table-wrapper table .domains-address { flex: 7 7 0; }
|
||||
#main-table-wrapper table .domains-homepage { flex: 5 5 0; }
|
||||
#main-table-wrapper table .domains-created-at { flex: 3 3 0; }
|
||||
#main-table-wrapper table .domains-links-count { flex: 3 3 0; justify-content: flex-end; overflow: visible; }
|
||||
#main-table-wrapper table .domains-actions { flex: 2 2 0; }
|
||||
|
||||
#main-table-wrapper table td.original-url,
|
||||
#main-table-wrapper table td.created-at,
|
||||
#main-table-wrapper.admin-table-wrapper table td.short-link,
|
||||
#main-table-wrapper table td.users-email,
|
||||
#main-table-wrapper table td.domains-address,
|
||||
#main-table-wrapper table td.users-created-at,
|
||||
#main-table-wrapper table td.users-verified {
|
||||
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 {
|
||||
table .short-link-wrapper { display: flex; align-items: center; }
|
||||
|
||||
#main-table-wrapper table td .description {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
}
|
||||
#main-table-wrapper table td .description a {
|
||||
color: #aaa;
|
||||
border-bottom-color: #aaa;
|
||||
}
|
||||
#main-table-wrapper table td .description svg {
|
||||
stroke: #aaa;
|
||||
stroke-width: 2;
|
||||
width: 0.85em;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
#main-table-wrapper table td .description span { color: #aaa; }
|
||||
#main-table-wrapper table td .description a:hover { border-bottom-color: transparent; }
|
||||
|
||||
#links-table-wrapper table tr.no-links {
|
||||
#main-table-wrapper table .status {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
#main-table-wrapper table .status:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#main-table-wrapper table .status.gray { background-color: hsl(200, 12%, 95%); }
|
||||
#main-table-wrapper table .status.green { background-color: hsl(102.4, 100%, 93.3%); }
|
||||
#main-table-wrapper table .status.red { background-color: hsl(0, 100%, 96.7%); }
|
||||
|
||||
#main-table-wrapper table tr.no-data {
|
||||
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; }
|
||||
#main-table-wrapper table.htmx-request tbody tr { opacity: 0.5; }
|
||||
#main-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 {
|
||||
#main-table-wrapper table tr.loading-placeholder td,
|
||||
#main-table-wrapper table tr.no-data td {
|
||||
flex: 0 0 auto;
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
#links-table-wrapper table tr.loading-placeholder svg.spinner {
|
||||
#main-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; }
|
||||
#main-table-wrapper table thead tr.controls { justify-content: space-between; }
|
||||
#main-table-wrapper table thead tr.controls.with-filters { align-items: flex-end; }
|
||||
#main-table-wrapper table tfoot tr.controls { justify-content: flex-end; }
|
||||
|
||||
#links-table-wrapper table th.search,
|
||||
#links-table-wrapper table th.nav {
|
||||
#main-table-wrapper table th.search {
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#main-table-wrapper table th.filters {
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
#main-table-wrapper table th.filters > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#main-table-wrapper table th.filters > div:last-child { margin-bottom: 0; }
|
||||
|
||||
#main-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 {
|
||||
#main-table-wrapper table tr.controls .checkbox {
|
||||
margin-left: 1rem;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
#links-table-wrapper table .limit,
|
||||
#links-table-wrapper table .pagination {
|
||||
#main-table-wrapper table .limit,
|
||||
#main-table-wrapper table .pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#links-table-wrapper table button.nav { margin-right: 0.75rem; }
|
||||
#links-table-wrapper table button.nav:last-child { margin-right: 0; }
|
||||
#main-table-wrapper table button.nav { margin-right: 0.75rem; }
|
||||
#main-table-wrapper table button.nav:last-child { margin-right: 0; }
|
||||
|
||||
#links-table-wrapper table .nav-divider {
|
||||
#main-table-wrapper table .nav-divider {
|
||||
height: 20px;
|
||||
width: 1px;
|
||||
opacity: 0.4;
|
||||
@ -1311,11 +1532,11 @@ main form label#advanced input {
|
||||
margin: 0 1.5rem;
|
||||
}
|
||||
|
||||
#links-table-wrapper table tbody tr:hover {
|
||||
#main-table-wrapper table tbody tr:hover {
|
||||
background-color: hsl(200, 14%, 98%);
|
||||
}
|
||||
|
||||
#links-table-wrapper table tbody td.right-fade:after {
|
||||
#main-table-wrapper table tbody td.right-fade:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@ -1325,48 +1546,48 @@ main form label#advanced input {
|
||||
background: linear-gradient(to left, white, rgba(255, 255, 255, 0.001));
|
||||
}
|
||||
|
||||
#links-table-wrapper table tbody tr:hover td.right-fade:after {
|
||||
#main-table-wrapper table tbody tr:hover td.right-fade:after {
|
||||
background: linear-gradient(to left, hsl(200, 14%, 98%), rgba(255, 255, 255, 0.001));
|
||||
}
|
||||
|
||||
|
||||
#links-table-wrapper table .clipboard { margin-right: 0.5rem; }
|
||||
#links-table-wrapper table .clipboard svg.check { width: 24px; }
|
||||
#main-table-wrapper table .clipboard { margin-right: 0.5rem; }
|
||||
#main-table-wrapper table .clipboard svg.check { width: 24px; }
|
||||
|
||||
#links-table-wrapper table tr.edit {
|
||||
#main-table-wrapper table tr.edit {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
#links-table-wrapper table tr.edit td {
|
||||
#main-table-wrapper table tr.edit td {
|
||||
width: 100%;
|
||||
padding: 2rem 1.5rem;
|
||||
flex-basis: auto;
|
||||
}
|
||||
#links-table-wrapper table tr.edit td form {
|
||||
#main-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 {
|
||||
#main-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; }
|
||||
#main-table-wrapper table tr.edit label { margin: 0 0.5rem 1rem; }
|
||||
#main-table-wrapper table tr.edit label:first-child { margin-left: 0; }
|
||||
#main-table-wrapper table tr.edit label:last-child { margin-right: 0; }
|
||||
|
||||
#links-table-wrapper table tr.edit input {
|
||||
#main-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 {
|
||||
#main-table-wrapper table tr.edit input,
|
||||
#main-table-wrapper table tr.edit input + p {
|
||||
width: 240px;
|
||||
max-width: 100%;
|
||||
font-size: 14px;
|
||||
@ -1374,40 +1595,40 @@ main form label#advanced input {
|
||||
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 {
|
||||
#main-table-wrapper table tr.edit input[name="target"],
|
||||
#main-table-wrapper table tr.edit input[name="description"],
|
||||
#main-table-wrapper table tr.edit input[name="target"] + p,
|
||||
#main-table-wrapper table tr.edit input[name="description"] + p {
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
#links-table-wrapper table tr.edit button {
|
||||
#main-table-wrapper table tr.edit button {
|
||||
height: 38px;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
#links-table-wrapper table tr.edit button:last-child { margin-right: 0; }
|
||||
#main-table-wrapper table tr.edit button:last-child { margin-right: 0; }
|
||||
|
||||
#links-table-wrapper table tr.edit form {
|
||||
#main-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; }
|
||||
#main-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; }
|
||||
#main-table-wrapper table tr.edit { display: none; }
|
||||
#main-table-wrapper table tr.edit.show { display: flex; }
|
||||
#main-table-wrapper table tr.edit td.loading { display: none; }
|
||||
#main-table-wrapper table tr.edit.htmx-request td.loading { display: block; }
|
||||
#main-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; }
|
||||
#main-table-wrapper table tr.edit form.htmx-request button .reload { display: none; }
|
||||
#main-table-wrapper table tr.edit form button .loader { display: none; }
|
||||
#main-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; }
|
||||
#main-table-wrapper table tr.edit form .response p { margin: 2rem 0 0; }
|
||||
|
||||
#links-table-wrapper table tr.edit p.no-links {
|
||||
#main-table-wrapper table tr.edit p.no-data {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
@ -1420,6 +1641,35 @@ main form label#advanced input {
|
||||
.dialog .ban-checklist label { margin: 1rem 1.5rem 1rem 0; }
|
||||
.dialog .ban-checklist label:last-child { margin-right: 0; }
|
||||
|
||||
#main-table-wrapper tr.category { justify-content: space-between; align-items: center; }
|
||||
#main-table-wrapper th.category-total { flex: 1 1 auto; }
|
||||
#main-table-wrapper th.category-total p { margin: 0; font-size: 15px; font-weight: normal }
|
||||
#main-table-wrapper th.category-tab { flex: 2 2 auto; justify-content: flex-end; }
|
||||
|
||||
/* ADMIN */
|
||||
|
||||
table .search-input-wrapper {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
input.search.admin {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.content.admin-create form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content.admin-create .checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content.admin-create .checkbox-wrapper label { margin-right: 1rem; }
|
||||
|
||||
.content.admin-create .buttons { justify-content: center; }
|
||||
.content.admin-create .buttons button { flex: 1 1 auto; }
|
||||
|
||||
/* INTRO */
|
||||
|
||||
@ -2040,11 +2290,12 @@ svg.map path.active { stroke: hsl(261, 46%, 50%); stroke-width: 1.5; }
|
||||
.dialog .loading { width: 20px; height: 20px; margin: 2rem 0; }
|
||||
.dialog .content .buttons { margin-top: 1rem; }
|
||||
|
||||
header { padding: 0 16px; height: 72px; }
|
||||
header { padding: 16px 16px 0; height: 72px; }
|
||||
header a.logo { font-size: 20px; }
|
||||
header ul.logo-links { display: none; }
|
||||
header .logo img { margin-right: 8px; }
|
||||
header nav ul li { margin-left: 1rem }
|
||||
header nav ul li { margin-left: 0.75rem }
|
||||
header nav ul li a.button { height: 28px; padding: 0 1rem; font-size: 11px; }
|
||||
|
||||
form#login-signup label { margin-bottom: 1.5rem; }
|
||||
form#login-signup input {
|
||||
@ -2070,37 +2321,47 @@ svg.map path.active { stroke: hsl(261, 46%, 50%); stroke-width: 1.5; }
|
||||
main form button.submit { width: 22px; top: 13px; margin: 0 1rem 0; }
|
||||
main form label#advanced { margin-top: 1.5rem; }
|
||||
main form label#advanced input { margin-bottom: 3px; }
|
||||
#links-table-wrapper { margin: 4rem 0 4.5rem;}
|
||||
#links-table-wrapper h2 { margin-bottom: 0.5rem; }
|
||||
#links-table-wrapper table thead,
|
||||
#links-table-wrapper table tbody,
|
||||
#links-table-wrapper table tfoot { min-width: 800px; }
|
||||
#links-table-wrapper tr { padding: 0 0.25rem; }
|
||||
#links-table-wrapper th,
|
||||
#links-table-wrapper td { padding: 0.75rem; }
|
||||
#links-table-wrapper table .actions a.button,
|
||||
#links-table-wrapper table .actions button { margin-right: 0.3rem; }
|
||||
#links-table-wrapper table td.original-url p.description,
|
||||
#links-table-wrapper table td.created-at p.expire-in { font-size: 12px; }
|
||||
#links-table-wrapper table tr.no-links td { font-size: 16px; }
|
||||
#links-table-wrapper table [name="search"] { height: 28px; font-size: 13px; padding: 0 1rem; }
|
||||
#links-table-wrapper table [name="search"]::placeholder { font-size: 12px; }
|
||||
#links-table-wrapper table tr.links-controls .checkbox { font-size: 13px; }
|
||||
#links-table-wrapper table button.nav { margin-right: 0.5rem; }
|
||||
#links-table-wrapper table .nav-divider { height: 18px; margin: 0 1rem; }
|
||||
#links-table-wrapper table tbody td.right-fade:after { width: 14px; }
|
||||
#links-table-wrapper table tr.edit td { padding: 1.25rem 1rem; }
|
||||
#links-table-wrapper table tr.edit label { margin: 0 0.25rem 0.5rem; }
|
||||
#links-table-wrapper table tr.edit input { height: 38px; padding: 0 1rem; font-size: 13px; }
|
||||
#links-table-wrapper table tr.edit input,
|
||||
#links-table-wrapper table tr.edit input + p { width: 200px; }
|
||||
#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: 320px; }
|
||||
#links-table-wrapper table tr.edit button { height: 32px; margin-right: 0.5rem; }
|
||||
#links-table-wrapper table tr.edit td.loading svg { width: 14px; height: 14px; }
|
||||
#links-table-wrapper table tr.edit form .response p { margin: 1rem 0 0; }
|
||||
#main-table-wrapper { margin: 4rem 0 4.5rem;}
|
||||
#main-table-wrapper h2 { margin-bottom: 0.5rem; }
|
||||
#main-table-wrapper table thead,
|
||||
#main-table-wrapper table tbody,
|
||||
#main-table-wrapper table tfoot { min-width: 800px; }
|
||||
#main-table-wrapper tr { padding: 0 0.25rem; }
|
||||
#main-table-wrapper th,
|
||||
#main-table-wrapper td { padding: 0.75rem; }
|
||||
#main-table-wrapper table .actions a.button,
|
||||
#main-table-wrapper table .actions button { margin-right: 0.3rem; }
|
||||
#main-table-wrapper table td p.description { font-size: 12px; }
|
||||
#main-table-wrapper table tr.no-data td { font-size: 16px; }
|
||||
#main-table-wrapper.admin-table-wrapper table th.nav { flex-direction: column; align-items: flex-end; }
|
||||
#main-table-wrapper.admin-table-wrapper table th .nav-divider { display: none; }
|
||||
#main-table-wrapper.admin-table-wrapper table th .limit { margin-bottom: 1rem; }
|
||||
table .tab a { padding: 0.3rem 0.9rem; }
|
||||
#main-table-wrapper th.category-total p { font-size: 13px; }
|
||||
#main-table-wrapper table thead tr.controls.with-filters { align-items: flex-start; }
|
||||
#main-table-wrapper table th select, input.table-input { height: 28px; font-size: 12px; padding: 0 1rem; }
|
||||
#main-table-wrapper table th select { background-position: right 0.7em top 50%, 0 0; }
|
||||
.search-input-wrapper button { padding: 2px; margin-right: 0.15rem; }
|
||||
#main-table-wrapper table th input.search.admin { max-width: 150px; padding: 0 1.5rem 0 1rem; }
|
||||
#main-table-wrapper table th select.table-input { max-width: 120px; }
|
||||
#main-table-wrapper table th button.table { height: 28px; }
|
||||
#main-table-wrapper table th input::placeholder { font-size: 12px; }
|
||||
#main-table-wrapper table tr.controls .checkbox { font-size: 13px; }
|
||||
#main-table-wrapper table button.nav { margin-right: 0.5rem; }
|
||||
#main-table-wrapper table .nav-divider { height: 18px; margin: 0 1rem; }
|
||||
#main-table-wrapper table tbody td.right-fade:after { width: 14px; }
|
||||
#main-table-wrapper table tr.edit td { padding: 1.25rem 1rem; }
|
||||
#main-table-wrapper table tr.edit label { margin: 0 0.25rem 0.5rem; }
|
||||
#main-table-wrapper table tr.edit input { height: 38px; padding: 0 1rem; font-size: 13px; }
|
||||
#main-table-wrapper table tr.edit input,
|
||||
#main-table-wrapper table tr.edit input + p { width: 200px; }
|
||||
#main-table-wrapper table tr.edit input[name="target"],
|
||||
#main-table-wrapper table tr.edit input[name="description"],
|
||||
#main-table-wrapper table tr.edit input[name="target"] + p,
|
||||
#main-table-wrapper table tr.edit input[name="description"] + p { width: 320px; }
|
||||
#main-table-wrapper table tr.edit button { height: 32px; margin-right: 0.5rem; }
|
||||
#main-table-wrapper table tr.edit td.loading svg { width: 14px; height: 14px; }
|
||||
#main-table-wrapper table tr.edit form .response p { margin: 1rem 0 0; }
|
||||
.dialog .ban-checklist label { margin: 0.5rem 1rem 0.5rem 0; }
|
||||
.introduction img { width: 90%; margin-top: 2rem; }
|
||||
|
||||
|
@ -85,11 +85,11 @@ function formatDateHour(selector) {
|
||||
}
|
||||
|
||||
// show QR code
|
||||
function handleQRCode(element) {
|
||||
const dialog = document.querySelector("#link-dialog");
|
||||
function handleQRCode(element, id) {
|
||||
const dialog = document.getElementById(id);
|
||||
const dialogContent = dialog.querySelector(".content-wrapper");
|
||||
if (!dialogContent) return;
|
||||
openDialog("link-dialog", "qrcode");
|
||||
openDialog(id, "qrcode");
|
||||
dialogContent.textContent = "";
|
||||
const qrcode = new QRCode(dialogContent, {
|
||||
text: element.dataset.url,
|
||||
@ -188,13 +188,14 @@ function updateLinksNav() {
|
||||
});
|
||||
}
|
||||
|
||||
function resetLinkNav() {
|
||||
function resetTableNav() {
|
||||
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 total = parseInt(totalElm.value);
|
||||
const skip = parseInt(skipElm.value);
|
||||
const limit = parseInt(limitElm.value);
|
||||
document.querySelectorAll('.pagination .next').forEach(elm => {
|
||||
@ -208,6 +209,66 @@ function resetLinkNav() {
|
||||
});
|
||||
}
|
||||
|
||||
// tab click
|
||||
function setTab(event, targetId) {
|
||||
const tabs = Array.from(closest("nav", event.target).children);
|
||||
tabs.forEach(function (tab) {
|
||||
tab.classList.remove("active");
|
||||
});
|
||||
if (targetId) {
|
||||
document.getElementById(targetId).classList.add("active");
|
||||
} else {
|
||||
event.target.classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
// show clear search button
|
||||
function onSearchChange(event) {
|
||||
const clearButton = event.target.parentElement.querySelector("button.clear");
|
||||
if (!clearButton) return;
|
||||
clearButton.style.display = event.target.value.length > 0 ? "block" : "none";
|
||||
}
|
||||
|
||||
function clearSeachInput(event) {
|
||||
event.preventDefault();
|
||||
const button = closest("button", event.target);
|
||||
const input = button.parentElement.querySelector("input");
|
||||
if (!input) return;
|
||||
input.value = "";
|
||||
button.style.display = "none";
|
||||
htmx.trigger("body", "reloadMainTable");
|
||||
}
|
||||
|
||||
// detect if search inputs have value on load to show clear button
|
||||
function onSearchInputLoad() {
|
||||
const linkSearchInput = document.getElementById("search");
|
||||
if (!linkSearchInput) return;
|
||||
const linkClearButton = linkSearchInput.parentElement.querySelector("button.clear")
|
||||
linkClearButton.style.display = linkSearchInput.value.length > 0 ? "block" : "none";
|
||||
|
||||
const userSearchInput = document.getElementById("search_user");
|
||||
if (!userSearchInput) return;
|
||||
const userClearButton = userSearchInput.parentElement.querySelector("button.clear")
|
||||
userClearButton.style.display = userSearchInput.value.length > 0 ? "block" : "none";
|
||||
|
||||
const domainSearchInput = document.getElementById("search_domain");
|
||||
if (!domainSearchInput) return;
|
||||
const domainClearButton = domainSearchInput.parentElement.querySelector("button.clear")
|
||||
domainClearButton.style.display = domainSearchInput.value.length > 0 ? "block" : "none";
|
||||
}
|
||||
|
||||
onSearchInputLoad();
|
||||
|
||||
// create user checkbox control
|
||||
function canSendVerificationEmail() {
|
||||
const canSendVerificationEmail = !document.getElementById('create-user-verified').checked && !document.getElementById('create-user-banned').checked;
|
||||
const checkbox = document.getElementById('send-email-label');
|
||||
if (canSendVerificationEmail)
|
||||
checkbox.classList.remove('hidden');
|
||||
if (!canSendVerificationEmail && !checkbox.classList.contains('hidden'))
|
||||
checkbox.classList.add('hidden');
|
||||
}
|
||||
|
||||
// create views chart label
|
||||
function createViewsChartLabel(ctx) {
|
||||
const period = ctx.dataset.period;
|
||||
|
Loading…
x
Reference in New Issue
Block a user