feat: Implement infinite scroll for the tags page by adding a paginated API endpoint and client-side loading logic.
This commit is contained in:
@@ -1,11 +1,53 @@
|
|||||||
import db from "../../inc/sql.mjs";
|
import db from "../../inc/sql.mjs";
|
||||||
import cfg from "../../inc/config.mjs";
|
import cfg from "../../inc/config.mjs";
|
||||||
|
|
||||||
|
const TAGS_PER_PAGE = 50;
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
|
// API endpoint for lazy loading tags
|
||||||
|
router.get(/^\/api\/tags$/, async (req, res) => {
|
||||||
|
const page = Math.max(1, +(req.url.qs?.page ?? 1));
|
||||||
|
const offset = (page - 1) * TAGS_PER_PAGE;
|
||||||
|
const isLoggedIn = !!req.session;
|
||||||
|
|
||||||
|
const nsfp = cfg.nsfp.map(n => `${n}`);
|
||||||
|
|
||||||
|
let tags;
|
||||||
|
if (isLoggedIn) {
|
||||||
|
tags = await db`
|
||||||
|
SELECT t.id, t.tag, COUNT(DISTINCT ta.item_id) AS total_items
|
||||||
|
FROM tags t
|
||||||
|
LEFT JOIN tags_assign ta ON t.id = ta.tag_id
|
||||||
|
GROUP BY t.id, t.tag
|
||||||
|
HAVING COUNT(DISTINCT ta.item_id) >= 1
|
||||||
|
ORDER BY total_items DESC
|
||||||
|
OFFSET ${offset}
|
||||||
|
LIMIT ${TAGS_PER_PAGE}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
tags = await db`
|
||||||
|
SELECT t.id, t.tag, COUNT(DISTINCT ta.item_id) AS total_items
|
||||||
|
FROM tags t
|
||||||
|
LEFT JOIN tags_assign ta ON t.id = ta.tag_id
|
||||||
|
WHERE t.id not in (${db.unsafe(nsfp)})
|
||||||
|
GROUP BY t.id, t.tag
|
||||||
|
HAVING COUNT(DISTINCT ta.item_id) >= 1
|
||||||
|
ORDER BY total_items DESC
|
||||||
|
OFFSET ${offset}
|
||||||
|
LIMIT ${TAGS_PER_PAGE}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
tags,
|
||||||
|
page,
|
||||||
|
hasMore: tags.length === TAGS_PER_PAGE
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main tags page - only load first page
|
||||||
router.get(/^\/tags$/, async (req, res) => {
|
router.get(/^\/tags$/, async (req, res) => {
|
||||||
|
|
||||||
const phrase = cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)];
|
const phrase = cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)];
|
||||||
|
|
||||||
const nsfp = cfg.nsfp.map(n => `${n}`);
|
const nsfp = cfg.nsfp.map(n => `${n}`);
|
||||||
|
|
||||||
const toptags = await db`
|
const toptags = await db`
|
||||||
@@ -16,7 +58,7 @@ export default (router, tpl) => {
|
|||||||
GROUP BY t.id, t.tag
|
GROUP BY t.id, t.tag
|
||||||
HAVING COUNT(DISTINCT ta.item_id) >= 1
|
HAVING COUNT(DISTINCT ta.item_id) >= 1
|
||||||
ORDER BY total_items DESC
|
ORDER BY total_items DESC
|
||||||
;
|
LIMIT ${TAGS_PER_PAGE}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const toptags_regged = await db`
|
const toptags_regged = await db`
|
||||||
@@ -26,7 +68,7 @@ export default (router, tpl) => {
|
|||||||
GROUP BY t.id, t.tag
|
GROUP BY t.id, t.tag
|
||||||
HAVING COUNT(DISTINCT ta.item_id) >= 1
|
HAVING COUNT(DISTINCT ta.item_id) >= 1
|
||||||
ORDER BY total_items DESC
|
ORDER BY total_items DESC
|
||||||
;
|
LIMIT ${TAGS_PER_PAGE}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
@@ -43,5 +85,6 @@ export default (router, tpl) => {
|
|||||||
}, req)
|
}, req)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,33 +2,109 @@
|
|||||||
<div id="main">
|
<div id="main">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h3 style="text-align: center;">☯</h3>
|
<h3 style="text-align: center;">☯</h3>
|
||||||
<div class="tags-grid">
|
<div class="tags-grid" id="tags-container">
|
||||||
@if(session)
|
@if(session)
|
||||||
@each(toptags_regged as toptag)
|
@each(toptags_regged as toptag)
|
||||||
<a href="/tag/{!! toptag.tag !!}" class="tag-card">
|
<a href="/tag/{{ toptag.tag }}" class="tag-card">
|
||||||
<div class="tag-card-image">
|
<div class="tag-card-image">
|
||||||
<img src="/tag_image/{!! toptag.tag !!}" loading="lazy" alt="{!! toptag.tag !!}">
|
<img src="/tag_image/{{ toptag.tag }}" loading="lazy" alt="{{ toptag.tag }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="tag-card-content">
|
<div class="tag-card-content">
|
||||||
<span class="tag-name">#{!! toptag.tag !!}</span>
|
<span class="tag-name">#{{ toptag.tag }}</span>
|
||||||
<span class="tag-count">{{ toptag.total_items }} posts</span>
|
<span class="tag-count">{{ toptag.total_items }} posts</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@endeach
|
@endeach
|
||||||
@else
|
@else
|
||||||
@each(toptags as toptag)
|
@each(toptags as toptag)
|
||||||
<a href="/tag/{!! toptag.tag !!}" class="tag-card">
|
<a href="/tag/{{ toptag.tag }}" class="tag-card">
|
||||||
<div class="tag-card-image">
|
<div class="tag-card-image">
|
||||||
<img src="/tag_image/{!! toptag.tag !!}" loading="lazy" alt="{!! toptag.tag !!}">
|
<img src="/tag_image/{{ toptag.tag }}" loading="lazy" alt="{{ toptag.tag }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="tag-card-content">
|
<div class="tag-card-content">
|
||||||
<span class="tag-name">#{!! toptag.tag !!}</span>
|
<span class="tag-name">#{{ toptag.tag }}</span>
|
||||||
<span class="tag-count">{{ toptag.total_items }} posts</span>
|
<span class="tag-count">{{ toptag.total_items }} posts</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@endeach
|
@endeach
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tags-loader" style="text-align: center; padding: 20px; display: none;">
|
||||||
|
<span style="color: #888;">Loading more tags...</span>
|
||||||
|
</div>
|
||||||
|
<div id="tags-end" style="text-align: center; padding: 20px; display: none;">
|
||||||
|
<span style="color: #666;">No more tags</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
let page = 1;
|
||||||
|
let loading = false;
|
||||||
|
let hasMore = true;
|
||||||
|
const container = document.getElementById('tags-container');
|
||||||
|
const loader = document.getElementById('tags-loader');
|
||||||
|
const endMsg = document.getElementById('tags-end');
|
||||||
|
|
||||||
|
const escapeHtml = (str) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (loading || !hasMore) return;
|
||||||
|
loading = true;
|
||||||
|
loader.style.display = 'block';
|
||||||
|
page++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tags?page=${page}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.tags || data.tags.length === 0) {
|
||||||
|
hasMore = false;
|
||||||
|
loader.style.display = 'none';
|
||||||
|
endMsg.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore = data.hasMore;
|
||||||
|
|
||||||
|
data.tags.forEach(tag => {
|
||||||
|
const escapedTag = escapeHtml(tag.tag);
|
||||||
|
const html = `
|
||||||
|
<a href="/tag/${encodeURIComponent(tag.tag)}" class="tag-card">
|
||||||
|
<div class="tag-card-image">
|
||||||
|
<img src="/tag_image/${encodeURIComponent(tag.tag)}" loading="lazy" alt="${escapedTag}">
|
||||||
|
</div>
|
||||||
|
<div class="tag-card-content">
|
||||||
|
<span class="tag-name">#${escapedTag}</span>
|
||||||
|
<span class="tag-count">${tag.total_items} posts</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasMore) {
|
||||||
|
endMsg.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load tags:', e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
loader.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Infinite scroll
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 500) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@include(snippets/footer)
|
@include(snippets/footer)
|
||||||
Reference in New Issue
Block a user