user tags

This commit is contained in:
2026-07-17 18:07:25 +02:00
parent a06beef458
commit 6da7951b7c
5 changed files with 235 additions and 5 deletions

View File

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

View File

@@ -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\/(?<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\/(?<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;
};

View File

@@ -0,0 +1,33 @@
<div class="profile-page-wrapper" style="max-width: 800px; margin: 0 auto; width: 100%; padding: 20px 10px; box-sizing: border-box;">
<div class="profile_head" data-banner-user="{{ user.user || '' }}" style="@if(user.banner_file && user_banner_enabled) --author-banner: url('/a/{{ user.banner_file }}'); --author-banner-position: {{ user.banner_position === 'center' ? 'center top' : (user.banner_position || 'center top') }}; --author-banner-size: {{ user.banner_size || 'cover' }}; @endif">
@if(user.avatar_file)
<div class="profile_head_avatar">
<img src="/a/{{ user.avatar_file }}" style="display: grid;width: 55px" />
</div>
@elseif(user.avatar && user.avatar > 0)
<div class="profile_head_avatar">
<img src="/t/{{ user.avatar }}.webp" style="display: grid;width: 55px" />
</div>
@endif
<div class="layersoffear">
<div class="profile_head_username">
<span @if(user.username_color) style="color: {{ user.username_color }}" @endif>{{ user.user }}'s Tags</span>
</div>
<div class="profile_head_user_stats">
<a href="/user/{!! user.user !!}">{{ t('profile.back_to_profile') }}</a>
</div>
</div>
</div>
<div class="user_content_wrapper" style="display: block;">
<div class="tags-grid" id="tags-container">
@include(tag-cards)
</div>
</div>
</div>
<div class="pagination-container-fluid" @if(typeof hidePagination !=='undefined' && hidePagination) style="display: none;" @endif>
<div class="pagination-wrapper bottom-pagination fixed-pagination">
@include(snippets/pagination)
</div>
</div>

9
views/tags_user.html Normal file
View File

@@ -0,0 +1,9 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
@include(tags_user-partial)
</div>
</div>
@include(snippets/footer)

View File

@@ -105,7 +105,7 @@
<div class="stat-joined" tooltip="{{ user.timestamp.timefull }}" data-iso="{{ user.timestamp.timefull }}">{{ t('profile.age_days', { n: user.age_days }) }}</div>
@if(!user.is_ghost)
<div class="stat-comments">{{ t('profile.stat_comments') }} <a href="/user/{!! user.user !!}/comments">{{ count.comments }}</a></div>
<div class="stat-tags">{{ t('profile.stat_tags') }} {{ count.tags }}</div>
<div class="stat-tags">{{ t('profile.stat_tags') }} <a href="/user/{!! user.user !!}/tags">{{ count.tags }}</a></div>
@if(!user.is_ghost)
<div class="stat-halls">{{ t('profile.stat_halls') }} <a href="/user/{!! user.user !!}/halls">{{ count.halls }}</a></div>
@endif