From 5303ffff6e32d842c6854ac458efff0acc43e33a Mon Sep 17 00:00:00 2001 From: jung Date: Mon, 28 Jul 2025 11:44:46 +0530 Subject: [PATCH 01/14] Rename mesasge.hbs to message.hbs Fixed Filename Typo --- server/views/partials/admin/dialog/{mesasge.hbs => message.hbs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/views/partials/admin/dialog/{mesasge.hbs => message.hbs} (100%) diff --git a/server/views/partials/admin/dialog/mesasge.hbs b/server/views/partials/admin/dialog/message.hbs similarity index 100% rename from server/views/partials/admin/dialog/mesasge.hbs rename to server/views/partials/admin/dialog/message.hbs From 5b1911130504f6e724eb228726aeb87f45e233be Mon Sep 17 00:00:00 2001 From: jung Date: Mon, 28 Jul 2025 11:58:32 +0530 Subject: [PATCH 02/14] Update visit.js Fixed method name typo --- server/queues/visit.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/queues/visit.js b/server/queues/visit.js index a05acd19b..f146ceb01 100644 --- a/server/queues/visit.js +++ b/server/queues/visit.js @@ -10,13 +10,13 @@ const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"]; function filterInBrowser(agent) { return function(item) { - return agent.family.toLowerCase().includes(item.toLocaleLowerCase()); + return agent.family.toLowerCase().includes(item.toLowerCase()); } } function filterInOs(agent) { return function(item) { - return agent.os.family.toLowerCase().includes(item.toLocaleLowerCase()); + return agent.os.family.toLowerCase().includes(item.toLowerCase()); } } @@ -48,4 +48,4 @@ module.exports = function({ data }) { ); return Promise.all(tasks); -} \ No newline at end of file +} From 59200471317371fb92f7f3f3e31c85a5a6d604fc Mon Sep 17 00:00:00 2001 From: jung Date: Fri, 1 Aug 2025 15:50:36 +0530 Subject: [PATCH 03/14] add sortBy and sortOrder params to get() and getAdmin() - sort by created_at or visit_count - defaults to created_at desc - uses ORDER BY in sql - falls back to id desc if invalid field - minor comment typo 'adddress' to 'addres' --- server/queries/link.queries.js | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/server/queries/link.queries.js b/server/queries/link.queries.js index 12f5480cc..26735e69e 100644 --- a/server/queries/link.queries.js +++ b/server/queries/link.queries.js @@ -123,8 +123,20 @@ async function get(match, params) { .select(...selectable) .where(normalizeMatch(match)) .offset(params.skip) - .limit(params.limit) - .orderBy("links.id", "desc"); + .limit(params.limit); + + // handle sorting + const sortBy = params.sortBy || "created_at"; + const sortOrder = params.sortOrder || "desc"; + + if (sortBy === "created_at") { + query.orderBy("links.created_at", sortOrder); + } else if (sortBy === "visit_count") { + query.orderBy("links.visit_count", sortOrder); + } else { + // default fallback to id desc for any invalid sortBy + query.orderBy("links.id", "desc"); + } if (params?.search) { query[knex.compatibleILIKE]( @@ -146,10 +158,22 @@ async function getAdmin(match, params) { }); query - .orderBy("links.id", "desc") .offset(params.skip) .limit(params.limit) + // handle sorting + const sortBy = params.sortBy || "created_at"; + const sortOrder = params.sortOrder || "desc"; + + if (sortBy === "created_at") { + query.orderBy("links.created_at", sortOrder); + } else if (sortBy === "visit_count") { + query.orderBy("links.visit_count", sortOrder); + } else { + // default fallback to id desc for any invalid sortBy + query.orderBy("links.id", "desc"); + } + if (params?.user) { const id = parseInt(params?.user); if (Number.isNaN(id)) { @@ -267,7 +291,7 @@ async function update(match, update) { update.password = await bcrypt.hash(update.password, salt); } - // if the links' adddress or domain is changed, + // if the links' address or domain is changed, // make sure to delete the original links from cache let links = [] if (env.REDIS_ENABLED && (update.address || update.domain_id)) { From 6027078404a16728c42352009e1a87de68145c9e Mon Sep 17 00:00:00 2001 From: jung Date: Fri, 1 Aug 2025 16:37:27 +0530 Subject: [PATCH 04/14] get sortBy and sortOrder from query string in get() and getAdmin() - pass to query.link.get() calls - send to handlebars template - keeps sort state on htmx requests - few minor comment typo --- server/handlers/links.handler.js | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/server/handlers/links.handler.js b/server/handlers/links.handler.js index 93a0a6489..fbc3db77a 100644 --- a/server/handlers/links.handler.js +++ b/server/handlers/links.handler.js @@ -19,6 +19,8 @@ const dnsLookup = promisify(dns.lookup); async function get(req, res) { const { limit, skip } = req.context; const search = req.query.search; + const sortBy = req.query.sortBy; + const sortOrder = req.query.sortOrder; const userId = req.user.id; const match = { @@ -26,7 +28,7 @@ async function get(req, res) { }; const [data, total] = await Promise.all([ - query.link.get(match, { limit, search, skip }), + query.link.get(match, { limit, search, skip, sortBy, sortOrder }), query.link.total(match, { search }) ]); @@ -35,6 +37,11 @@ async function get(req, res) { total, limit, skip, + query: { + sortBy: sortBy || "created_at", + sortOrder: sortOrder || "desc", + search + }, links: data.map(utils.sanitize.link_html), }) return; @@ -53,6 +60,8 @@ async function getAdmin(req, res) { const search = req.query.search; const user = req.query.user; let domain = req.query.domain; + const sortBy = req.query.sortBy; + const sortOrder = req.query.sortOrder; const banned = utils.parseBooleanQuery(req.query.banned); const anonymous = utils.parseBooleanQuery(req.query.anonymous); const has_domain = utils.parseBooleanQuery(req.query.has_domain); @@ -63,15 +72,15 @@ async function getAdmin(req, res) { ...(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 is equal to the default domain, + // it means admin is looking for links with the default 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.getAdmin(match, { limit, search, user, domain, skip, sortBy, sortOrder }), query.link.totalAdmin(match, { search, user, domain }) ]); @@ -83,6 +92,16 @@ async function getAdmin(req, res) { total_formatted: total.toLocaleString("en-US"), limit, skip, + query: { + sortBy: sortBy || "created_at", + sortOrder: sortOrder || "desc", + search, + user, + domain, + banned: req.query.banned, + anonymous: req.query.anonymous, + has_domain: req.query.has_domain + }, links, }) return; @@ -659,4 +678,4 @@ module.exports = { redirect, redirectProtected, redirectCustomDomainHomepage, -} \ No newline at end of file +} From 9422a4fb282d2e52d8c392b078cd9364931710ab Mon Sep 17 00:00:00 2001 From: jung Date: Fri, 1 Aug 2025 16:44:14 +0530 Subject: [PATCH 05/14] clickable headers for created at and views columns - hidden inputs store sort state - onclick calls sortBy() function - added sort indicator spans --- server/views/partials/links/thead.hbs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/server/views/partials/links/thead.hbs b/server/views/partials/links/thead.hbs index 0de057826..2aa4aa040 100644 --- a/server/views/partials/links/thead.hbs +++ b/server/views/partials/links/thead.hbs @@ -5,14 +5,22 @@ + + {{> links/nav}} Original URL - Created at + + Created at + + Short link - Views + + Views + + - \ No newline at end of file + From ab3e2f44eb18e8c8597c89bafe4082fa80145eb1 Mon Sep 17 00:00:00 2001 From: jung Date: Fri, 1 Aug 2025 16:46:17 +0530 Subject: [PATCH 06/14] clickable headers for created at and views columns - hidden inputs store sort state - onclick calls sortBy() function - added sort indicator spans - matches user panel --- server/views/partials/admin/links/thead.hbs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/server/views/partials/admin/links/thead.hbs b/server/views/partials/admin/links/thead.hbs index 42d9b91e0..77ed0cbb3 100644 --- a/server/views/partials/admin/links/thead.hbs +++ b/server/views/partials/admin/links/thead.hbs @@ -98,15 +98,23 @@ + + {{> admin/table_nav}} Original URL - Created at + + Created at + + Short link - Views + + Views + + - \ No newline at end of file + From 3596a2a8683c214f0873959c25edaf784eac8e88 Mon Sep 17 00:00:00 2001 From: jung Date: Fri, 1 Aug 2025 16:52:02 +0530 Subject: [PATCH 07/14] add table sorting js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sortBy() handles column clicks and toggles asc/desc updateSortIndicators() shows arrows (↑↓) initializeSortIndicators() runs on page load and htmx updates resets pagination when sorting changes --- static/scripts/main.js | 67 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/static/scripts/main.js b/static/scripts/main.js index ab29a8e60..5b5c0dec6 100644 --- a/static/scripts/main.js +++ b/static/scripts/main.js @@ -209,6 +209,71 @@ function resetTableNav() { }); } +function sortBy(field) { + const sortByElm = document.querySelector("#sortBy"); + const sortOrderElm = document.querySelector("#sortOrder"); + const skipElm = document.querySelector("#skip"); + + if (!sortByElm || !sortOrderElm) return; + + // reset pagination when sorting + if (skipElm) skipElm.value = 0; + + // if clicking the same field, toggle order. otherwise set to desc + if (sortByElm.value === field) { + sortOrderElm.value = sortOrderElm.value === "desc" ? "asc" : "desc"; + } else { + sortByElm.value = field; + sortOrderElm.value = "desc"; + } + + // update sort indicators in the UI + updateSortIndicators(sortByElm.value, sortOrderElm.value); + + // trigger table reload + const table = document.querySelector("table[hx-get]"); + if (table) { + htmx.trigger(table, "reloadMainTable"); + } +} + +function updateSortIndicators(sortBy, sortOrder) { + // clear all sort indicators + document.querySelectorAll(".sort-indicator").forEach(indicator => { + indicator.textContent = ""; + }); + + // find the active column header and set the indicator + const activeHeader = document.querySelector(`.sortable[onclick="sortBy('${sortBy}')"]`); + if (activeHeader) { + const indicator = activeHeader.querySelector(".sort-indicator"); + if (indicator) { + indicator.textContent = sortOrder === "asc" ? "↑" : "↓"; + } + } +} + +function initializeSortIndicators() { + const sortByElm = document.querySelector("#sortBy"); + const sortOrderElm = document.querySelector("#sortOrder"); + + if (sortByElm && sortOrderElm) { + const currentSortBy = sortByElm.value || "created_at"; + const currentSortOrder = sortOrderElm.value || "desc"; + updateSortIndicators(currentSortBy, currentSortOrder); + } +} + +// initialize sort indicators when the page loads +document.addEventListener("DOMContentLoaded", initializeSortIndicators); + +// also initialize sort indicators after HTMX updates the table +document.body.addEventListener("htmx:afterSettle", function(evt) { + if (evt.detail.target.tagName === "TBODY" || evt.detail.target.tagName === "TABLE") { + initializeSortIndicators(); + } +}); + // tab click function setTab(event, targetId) { const tabs = Array.from(closest("nav", event.target).children); @@ -359,4 +424,4 @@ htmx.defineExtension("preload", { node.querySelectorAll("a,[hx-get],[data-hx-get]").forEach(init) }) } -}) \ No newline at end of file +}) From df403fc52735dfba9237eb8f6ee2caa029ae3c6c Mon Sep 17 00:00:00 2001 From: jung Date: Fri, 1 Aug 2025 17:14:02 +0530 Subject: [PATCH 08/14] table header styles and sorting css sortable columns get pointer cursor and hover effect sort arrows styled with margin and color fixed justify-content on .views, .users-links-count, .domains-links-count headers data cells stay right-aligned --- static/css/styles.css | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/static/css/styles.css b/static/css/styles.css index 2bd96ef4f..2b50a7901 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1673,6 +1673,30 @@ table .short-link-wrapper { display: flex; align-items: center; } #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; } +/* SORTABLE COLUMNS */ + +.sortable { + cursor: pointer; + user-select: none; + transition: background-color 0.2s ease; +} + +.sortable:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.sort-indicator { + margin-left: 0.5rem; + font-size: 0.8rem; + color: #666; +} + +#main-table-wrapper table thead .views, +#main-table-wrapper table thead .users-links-count, +#main-table-wrapper table thead .domains-links-count { + justify-content: initial; +} + /* ADMIN */ table .search-input-wrapper { @@ -2318,4 +2342,4 @@ svg.map path.active { stroke: hsl(261, 46%, 50%); stroke-width: 1.5; } #apikey-wrapper { max-width: 100%; } #apikey p { font-size: 0.85rem; } #apikey .clipboard { width: 22px; height: 22px; } -} \ No newline at end of file +} From 2c413a1ca974c2eb09e6b2c97a2948c3f558b71a Mon Sep 17 00:00:00 2001 From: jung Date: Fri, 1 Aug 2025 17:23:30 +0530 Subject: [PATCH 09/14] add sortBy and sortOrder params to getAdmin() - sort by created_at or links_count - defaults to created_at desc - ORDER BY with join count - fallback to id desc --- server/queries/user.queries.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/server/queries/user.queries.js b/server/queries/user.queries.js index 857f3e3b2..acd4836fb 100644 --- a/server/queries/user.queries.js +++ b/server/queries/user.queries.js @@ -136,10 +136,22 @@ async function getAdmin(match, params) { .where(normalizeMatch(match)) .offset(params.skip) .limit(params.limit) - .orderBy("users.id", "desc") .groupBy(1) .groupBy("l.links_count") .groupBy("d.domains"); + + // handle sorting + const sortBy = params.sortBy || "created_at"; + const sortOrder = params.sortOrder || "desc"; + + if (sortBy === "created_at") { + query.orderBy("users.created_at", sortOrder); + } else if (sortBy === "links_count") { + query.orderBy("l.links_count", sortOrder); + } else { + // Default fallback + query.orderBy("users.id", "desc"); + } if (params?.search) { const id = parseInt(params?.search); From a4ce0232fa10499e3f7ecfa14a8ec9e4fea2a996 Mon Sep 17 00:00:00 2001 From: jung Date: Fri, 1 Aug 2025 17:28:36 +0530 Subject: [PATCH 10/14] add sortBy and sortOrder params to getAdmin() - sort by created_at or links_count - defaults to created_at desc - ORDER BY with join count - fallback to id desc - minor comment typo 'adddress' to 'address' --- server/queries/domain.queries.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/server/queries/domain.queries.js b/server/queries/domain.queries.js index d4d15674f..05261bd0d 100644 --- a/server/queries/domain.queries.js +++ b/server/queries/domain.queries.js @@ -61,7 +61,7 @@ async function add(params) { } async function update(match, update) { - // if the domains' adddress is changed, + // if the domains' address is changed, // make sure to delete the original domains from cache let domains = [] if (env.REDIS_ENABLED && update.address) { @@ -134,11 +134,23 @@ async function getAdmin(match, params) { .offset(params.skip) .limit(params.limit) .fromRaw("domains") - .orderBy("domains.id", "desc") .groupBy(1) .groupBy("l.links_count") .groupBy("users.email"); + // Handle sorting + const sortBy = params.sortBy || "created_at"; + const sortOrder = params.sortOrder || "desc"; + + if (sortBy === "created_at") { + query.orderBy("domains.created_at", sortOrder); + } else if (sortBy === "links_count") { + query.orderBy("l.links_count", sortOrder); + } else { + // Default fallback + query.orderBy("domains.id", "desc"); + } + if (params?.user) { const id = parseInt(params?.user); if (Number.isNaN(id)) { @@ -228,4 +240,4 @@ module.exports = { remove, totalAdmin, update, -} \ No newline at end of file +} From 8eb5b93cc8dfda0ffbc3fdeec58e9559a6cac334 Mon Sep 17 00:00:00 2001 From: jung Date: Fri, 1 Aug 2025 17:35:20 +0530 Subject: [PATCH 11/14] get sortBy and sortOrder from query string in getAdmin() - pass to query.user.getAdmin() - send to handlebars template - keeps sort state on htmx requests --- server/handlers/users.handler.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/server/handlers/users.handler.js b/server/handlers/users.handler.js index 99023d8e1..bb1641738 100644 --- a/server/handlers/users.handler.js +++ b/server/handlers/users.handler.js @@ -5,6 +5,8 @@ const utils = require("../utils"); const mail = require("../mail"); const env = require("../env"); +const CustomError = utils.CustomError; + async function get(req, res) { const domains = await query.domain.get({ user_id: req.user.id }); @@ -63,7 +65,7 @@ async function removeByAdmin(req, res) { async function getAdmin(req, res) { const { limit, skip, all } = req.context; - const { role, search } = req.query; + const { role, search, sortBy, sortOrder } = req.query; const userId = req.user.id; const verified = utils.parseBooleanQuery(req.query.verified); const banned = utils.parseBooleanQuery(req.query.banned); @@ -77,7 +79,7 @@ async function getAdmin(req, res) { }; const [data, total] = await Promise.all([ - query.user.getAdmin(match, { limit, search, domains, links, skip }), + query.user.getAdmin(match, { limit, search, domains, links, skip, sortBy, sortOrder }), query.user.totalAdmin(match, { search, domains, links }) ]); @@ -89,6 +91,16 @@ async function getAdmin(req, res) { total_formatted: total.toLocaleString("en-US"), limit, skip, + query: { + sortBy: sortBy || "created_at", + sortOrder: sortOrder || "desc", + search, + role, + verified: req.query.verified, + banned: req.query.banned, + domains: req.query.domains, + links: req.query.links + }, users, }) return; @@ -138,7 +150,8 @@ async function ban(req, res) { // 5. wait for all tasks to finish await Promise.all(tasks).catch((err) => { - throw new CustomError("Couldn't ban entries."); + console.error("User ban operation failed:", err); + throw new CustomError(`Couldn't ban entries: ${err.message}`); }); // 6. send response @@ -182,4 +195,4 @@ module.exports = { getAdmin, remove, removeByAdmin, -} \ No newline at end of file +} From 55174b426dd5a64003cce33ddf17effa2c5c3fce Mon Sep 17 00:00:00 2001 From: jung Date: Fri, 1 Aug 2025 17:45:56 +0530 Subject: [PATCH 12/14] get sortBy and sortOrder from query string in getAdmin() - pass to query.domain.getAdmin() - send to handlebars template - keeps sort state on htmx requests - add missing semicolon --- server/handlers/domains.handler.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/server/handlers/domains.handler.js b/server/handlers/domains.handler.js index 75a9d0197..6b377322b 100644 --- a/server/handlers/domains.handler.js +++ b/server/handlers/domains.handler.js @@ -86,7 +86,7 @@ async function remove(req, res) { async function removeAdmin(req, res) { const id = req.params.id; - const links = req.query.links + const links = req.query.links; const domain = await query.domain.find({ id }); @@ -116,6 +116,8 @@ async function getAdmin(req, res) { const { limit, skip } = req.context; const search = req.query.search; const user = req.query.user; + const sortBy = req.query.sortBy; + const sortOrder = req.query.sortOrder; const banned = utils.parseBooleanQuery(req.query.banned); const owner = utils.parseBooleanQuery(req.query.owner); const links = utils.parseBooleanQuery(req.query.links); @@ -126,7 +128,7 @@ async function getAdmin(req, res) { }; const [data, total] = await Promise.all([ - query.domain.getAdmin(match, { limit, search, user, links, skip }), + query.domain.getAdmin(match, { limit, search, user, links, skip, sortBy, sortOrder }), query.domain.totalAdmin(match, { search, user, links }) ]); @@ -138,6 +140,15 @@ async function getAdmin(req, res) { total_formatted: total.toLocaleString("en-US"), limit, skip, + query: { + sortBy: sortBy || "created_at", + sortOrder: sortOrder || "desc", + search, + user, + banned: req.query.banned, + owner: req.query.owner, + links: req.query.links + }, table_domains: domains, }) return; @@ -210,4 +221,4 @@ module.exports = { getAdmin, remove, removeAdmin, -} \ No newline at end of file +} From 3fe9d4108af02ab113edf14f4d266c13031d3a6f Mon Sep 17 00:00:00 2001 From: jung Date: Fri, 1 Aug 2025 17:48:47 +0530 Subject: [PATCH 13/14] clickable headers for created at and total links - hidden inputs store sort state - onclick calls sortBy() function - added sort indicator spans --- server/views/partials/admin/users/thead.hbs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/server/views/partials/admin/users/thead.hbs b/server/views/partials/admin/users/thead.hbs index 330c41dd6..b2f738285 100644 --- a/server/views/partials/admin/users/thead.hbs +++ b/server/views/partials/admin/users/thead.hbs @@ -53,6 +53,8 @@ + +