user tags
This commit is contained in:
@@ -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);
|
||||
|
||||
184
src/inc/routes/user_tags.mjs
Normal file
184
src/inc/routes/user_tags.mjs
Normal 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;
|
||||
};
|
||||
33
views/tags_user-partial.html
Normal file
33
views/tags_user-partial.html
Normal 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
9
views/tags_user.html
Normal file
@@ -0,0 +1,9 @@
|
||||
@include(snippets/header)
|
||||
|
||||
<div class="pagewrapper">
|
||||
<div id="main">
|
||||
@include(tags_user-partial)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include(snippets/footer)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user