From 6da7951b7c2410a15fc70e9ba8e548f46d79c433 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Fri, 17 Jul 2026 18:07:25 +0200 Subject: [PATCH] user tags --- public/s/js/f0ckm.js | 12 ++- src/inc/routes/user_tags.mjs | 184 +++++++++++++++++++++++++++++++++++ views/tags_user-partial.html | 33 +++++++ views/tags_user.html | 9 ++ views/user-partial.html | 2 +- 5 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 src/inc/routes/user_tags.mjs create mode 100644 views/tags_user-partial.html create mode 100644 views/tags_user.html diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 8ce1d30..755eab7 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -4697,6 +4697,7 @@ window.cancelAnimFrame = (function () { ctx.user = decodeURIComponent(userMatch[1]); if (window.location.pathname.includes('/favs')) ctx.fav = true; if (window.location.pathname.includes('/f0cks')) ctx.f0cks = true; + if (window.location.pathname.includes('/tags')) ctx.userTags = true; } const mimeMatch = window.location.pathname.match(/\/(image|audio|video)(?:\/|$)/); @@ -4726,6 +4727,7 @@ window.cancelAnimFrame = (function () { path += `user/${ctx.user}/`; if (ctx.fav) path += `favs/`; else if (ctx.f0cks) path += `f0cks/`; + else if (ctx.userTags) path += `tags/`; } if (ctx.mime) path += `${ctx.mime}/`; if (page > 1) path += `p/${page}`; @@ -4897,8 +4899,9 @@ window.cancelAnimFrame = (function () { const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` : ctx.tags ? `/api/tags?ajax=true&${params.toString()}` : - ctx.subs ? `/ajax/subscriptions?${params.toString()}` : - `/ajax/items?${params.toString()}`; + ctx.userTags ? `/api/user/${encodeURIComponent(ctx.user)}/tags?ajax=true&${params.toString()}` : + ctx.subs ? `/ajax/subscriptions?${params.toString()}` : + `/ajax/items?${params.toString()}`; try { const response = await fetch(fetchUrl); @@ -4997,8 +5000,9 @@ window.cancelAnimFrame = (function () { const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` : ctx.tags ? `/api/tags?ajax=true&${params.toString()}` : - ctx.subs ? `/ajax/subscriptions?${params.toString()}` : - `/ajax/items?${params.toString()}`; + ctx.userTags ? `/api/user/${encodeURIComponent(ctx.user)}/tags?ajax=true&${params.toString()}` : + ctx.subs ? `/ajax/subscriptions?${params.toString()}` : + `/ajax/items?${params.toString()}`; try { const response = await fetch(fetchUrl); diff --git a/src/inc/routes/user_tags.mjs b/src/inc/routes/user_tags.mjs new file mode 100644 index 0000000..7edf2b3 --- /dev/null +++ b/src/inc/routes/user_tags.mjs @@ -0,0 +1,184 @@ +import db from "../../inc/sql.mjs"; +import lib from "../../inc/lib.mjs"; +import cfg from "../../inc/config.mjs"; +import url from "url"; + +const TAGS_PER_PAGE = 50; // Smaller chunks for better infinite scroll + +export default (router, tpl) => { + const getTagsQuery = async (userId, mode, offset, limit, sessionObj = false, strict = false) => { + const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : []; + const isGuest = !sessionObj; + const modequery = lib.getMode(mode); + + let restrictedFilter = db``; + if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) { + restrictedFilter = db` + AND t.id NOT IN ${db(cfg.nsfp)} + AND NOT EXISTS ( + SELECT 1 FROM tags_assign ta_res + WHERE ta_res.item_id = items.id + AND ta_res.tag_id IN ${db(cfg.nsfp)} + ) + `; + } + + const userExcludeFilter = excludedTags.length > 0 + ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign ta_ex WHERE ta_ex.item_id = items.id AND ta_ex.tag_id = ANY(${excludedTags}::int[]))` + : db``; + + // Step 1: Get tags sorted by exact count (fast, indexed) + // Group by normalized to merge duplicates (e.g. "Music" and "music") + const baseTags = await db` + SELECT MIN(t.id) as id, MIN(t.tag) as tag, t.normalized, COUNT(DISTINCT items.id) AS total_items + FROM tags t + JOIN tags_assign ta ON t.id = ta.tag_id + JOIN items ON items.id = ta.item_id + WHERE items.active = true + AND t.id NOT IN (1, 2) + AND ta.user_id = ${userId} + AND ${db.unsafe(modequery)} + ${restrictedFilter} + ${userExcludeFilter} + GROUP BY t.normalized + HAVING COUNT(DISTINCT items.id) >= 1 + ORDER BY total_items DESC, MIN(t.id) DESC + OFFSET ${offset} + LIMIT ${limit} + `; + + // Step 2: In normal (non-strict) mode, replace counts with fuzzy counts + // Only runs for the ~50 tags being displayed, not all tags + if (!strict && baseTags.length > 0) { + await Promise.all(baseTags.map(async (tag) => { + if (!tag.normalized) return; + const [row] = await db` + SELECT COUNT(DISTINCT items.id) as total + FROM tags t + JOIN tags_assign ta ON t.id = ta.tag_id + JOIN items ON items.id = ta.item_id + WHERE t.normalized LIKE '%' || ${tag.normalized} || '%' + AND items.active = true + AND ta.user_id = ${userId} + AND ${db.unsafe(modequery)} + ${restrictedFilter} + ${userExcludeFilter} + `; + tag.total_items = +row.total; + })); + } + + return baseTags; + }; + + const processTags = (tags) => tags.map(t => ({ + ...t, + safe_tag: t.normalized || encodeURIComponent(t.tag), + encoded_tag: encodeURIComponent(t.tag) + })); + + // API endpoint for lazy loading tags for a user + router.get(/^\/api\/user\/(?[^\/]+)\/tags$/, async (req, res) => { + const userParam = decodeURIComponent(req.params.user); + + const u = await db` + SELECT "user".id + FROM "user" + WHERE "user".user ILIKE ${userParam} + `; + if (!u.length) { + return res.reply({ code: 404, body: JSON.stringify({ success: false, msg: "User not found" }) }); + } + const userId = u[0].id; + + let query = {}; + if (typeof req.url === 'string') { + const parsedUrl = url.parse(req.url, true); + query = parsedUrl.query; + } else { + query = req.url.qs || {}; + } + + const page = Math.max(1, +(query.page ?? 1)); + const offset = (page - 1) * TAGS_PER_PAGE; + const mode = req.mode ?? 0; + const isStrict = !!(query.strict === '1' || req.session?.strict_mode); + + const tags = processTags(await getTagsQuery(userId, mode, offset, TAGS_PER_PAGE, req.session, isStrict)); + + if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) { + return res.json({ + success: true, + html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req), + currentPage: page, + hasMore: tags.length === TAGS_PER_PAGE + }); + } + + res.json({ + success: true, + tags, + currentPage: page, + hasMore: tags.length === TAGS_PER_PAGE + }); + }); + + // Main tags page + router.get(/^\/user\/(?[^\/]+)\/tags$/, async (req, res) => { + const userParam = decodeURIComponent(req.params.user); + + const u = await db` + SELECT "user".id, "user".user, user_options.avatar, user_options.avatar_file, user_options.username_color + FROM "user" + LEFT JOIN user_options ON "user".id = user_options.user_id + WHERE "user".user ILIKE ${userParam} + `; + if (!u.length) { + return res.reply({ code: 404, body: "User not found" }); + } + const userRow = u[0]; + const userId = userRow.id; + + const mode = req.mode ?? 0; + const query = req.url.qs || {}; + const isStrict = !!(query.strict === '1' || req.session?.strict_mode); + + const toptags = processTags(await getTagsQuery(userId, mode, 0, TAGS_PER_PAGE, req.session, isStrict)); + + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.setHeader('Surrogate-Control', 'no-store'); + + const data = { + user: userRow, + toptags: toptags, + tmp: null, + hidePagination: false, + pagination: { + page: 1, + prev: null, + next: toptags.length === TAGS_PER_PAGE ? 2 : null, + cheat: [1] + }, + session: (req.session && req.session.user) ? { ...req.session } : false, + page_meta: { + title: `${userRow.user}'s Tags`, + description: `Browse ${toptags.length}+ tags made by ${userRow.user}`, + url: `https://${cfg.main.url.domain}/user/${encodeURIComponent(userRow.user)}/tags` + } + }; + + if (req.headers['x-requested-with'] === 'XMLHttpRequest') { + return res.reply({ + body: tpl.render('tags_user-partial', data, req) + }); + } + + res.reply({ + body: tpl.render('tags_user', data, req) + }); + }); + + return router; +}; diff --git a/views/tags_user-partial.html b/views/tags_user-partial.html new file mode 100644 index 0000000..24374d6 --- /dev/null +++ b/views/tags_user-partial.html @@ -0,0 +1,33 @@ +
+
+ @if(user.avatar_file) +
+ +
+ @elseif(user.avatar && user.avatar > 0) +
+ +
+ @endif +
+
+ {{ user.user }}'s Tags +
+ +
+
+ +
+
+ @include(tag-cards) +
+
+
+ + diff --git a/views/tags_user.html b/views/tags_user.html new file mode 100644 index 0000000..6f46b98 --- /dev/null +++ b/views/tags_user.html @@ -0,0 +1,9 @@ +@include(snippets/header) + +
+
+ @include(tags_user-partial) +
+
+ +@include(snippets/footer) diff --git a/views/user-partial.html b/views/user-partial.html index baa8a4b..85ce030 100644 --- a/views/user-partial.html +++ b/views/user-partial.html @@ -105,7 +105,7 @@
{{ t('profile.age_days', { n: user.age_days }) }}
@if(!user.is_ghost)
{{ t('profile.stat_comments') }} {{ count.comments }}
-
{{ t('profile.stat_tags') }} {{ count.tags }}
+
{{ t('profile.stat_tags') }} {{ count.tags }}
@if(!user.is_ghost)
{{ t('profile.stat_halls') }} {{ count.halls }}
@endif