user tags
This commit is contained in:
@@ -4697,6 +4697,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
ctx.user = decodeURIComponent(userMatch[1]);
|
ctx.user = decodeURIComponent(userMatch[1]);
|
||||||
if (window.location.pathname.includes('/favs')) ctx.fav = true;
|
if (window.location.pathname.includes('/favs')) ctx.fav = true;
|
||||||
if (window.location.pathname.includes('/f0cks')) ctx.f0cks = 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)(?:\/|$)/);
|
const mimeMatch = window.location.pathname.match(/\/(image|audio|video)(?:\/|$)/);
|
||||||
@@ -4726,6 +4727,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
path += `user/${ctx.user}/`;
|
path += `user/${ctx.user}/`;
|
||||||
if (ctx.fav) path += `favs/`;
|
if (ctx.fav) path += `favs/`;
|
||||||
else if (ctx.f0cks) path += `f0cks/`;
|
else if (ctx.f0cks) path += `f0cks/`;
|
||||||
|
else if (ctx.userTags) path += `tags/`;
|
||||||
}
|
}
|
||||||
if (ctx.mime) path += `${ctx.mime}/`;
|
if (ctx.mime) path += `${ctx.mime}/`;
|
||||||
if (page > 1) path += `p/${page}`;
|
if (page > 1) path += `p/${page}`;
|
||||||
@@ -4897,8 +4899,9 @@ window.cancelAnimFrame = (function () {
|
|||||||
|
|
||||||
const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` :
|
const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` :
|
||||||
ctx.tags ? `/api/tags?ajax=true&${params.toString()}` :
|
ctx.tags ? `/api/tags?ajax=true&${params.toString()}` :
|
||||||
ctx.subs ? `/ajax/subscriptions?${params.toString()}` :
|
ctx.userTags ? `/api/user/${encodeURIComponent(ctx.user)}/tags?ajax=true&${params.toString()}` :
|
||||||
`/ajax/items?${params.toString()}`;
|
ctx.subs ? `/ajax/subscriptions?${params.toString()}` :
|
||||||
|
`/ajax/items?${params.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(fetchUrl);
|
const response = await fetch(fetchUrl);
|
||||||
@@ -4997,8 +5000,9 @@ window.cancelAnimFrame = (function () {
|
|||||||
|
|
||||||
const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` :
|
const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` :
|
||||||
ctx.tags ? `/api/tags?ajax=true&${params.toString()}` :
|
ctx.tags ? `/api/tags?ajax=true&${params.toString()}` :
|
||||||
ctx.subs ? `/ajax/subscriptions?${params.toString()}` :
|
ctx.userTags ? `/api/user/${encodeURIComponent(ctx.user)}/tags?ajax=true&${params.toString()}` :
|
||||||
`/ajax/items?${params.toString()}`;
|
ctx.subs ? `/ajax/subscriptions?${params.toString()}` :
|
||||||
|
`/ajax/items?${params.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(fetchUrl);
|
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>
|
<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)
|
@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-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)
|
@if(!user.is_ghost)
|
||||||
<div class="stat-halls">{{ t('profile.stat_halls') }} <a href="/user/{!! user.user !!}/halls">{{ count.halls }}</a></div>
|
<div class="stat-halls">{{ t('profile.stat_halls') }} <a href="/user/{!! user.user !!}/halls">{{ count.halls }}</a></div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
Reference in New Issue
Block a user