refactor: update card grid filtering and pagination logic to support category/tag filters and display empty states

This commit is contained in:
2026-08-03 20:34:15 +07:00
parent 141d3a71fd
commit 417f6ecb24
5 changed files with 90 additions and 30 deletions
@@ -188,13 +188,22 @@
<button type="button" class="tab-btn active" data-filter="ALL">Tất cả</button>
</div>
<!-- 2. CARD GRID -->
<div class="news-grid" th:data-items-per-page="${block.data['itemsPerPage'] != null ? block.data['itemsPerPage'] : (block.data['limit'] != null ? block.data['limit'] : 9)}">
<th:block th:each="p : ${posts}" th:if="${(block.data['layoutFilter'] == null or block.data['layoutFilter'] == 'ALL' or (p.layout != null and #strings.equals(p.layout.name(), block.data['layoutFilter'])))}">
<!-- 2. CARD GRID (Strictly capped at 12 items per page max) -->
<div class="news-grid" th:data-items-per-page="${block.data['limit'] != null and block.data['limit'] > 0 ? block.data['limit'] : 12}">
<th:block th:each="p : ${posts}"
th:if="${(
(block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or #strings.contains(block.data['categoryFilter'], 'ALL'))
and
(block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or #strings.contains(block.data['tagFilter'], 'ALL'))
) or (
(p.category != null and block.data['categoryFilter'] != null and !#strings.isEmpty(block.data['categoryFilter']) and #strings.containsIgnoreCase(block.data['categoryFilter'], p.category.name))
or
(p.tags != null and block.data['tagFilter'] != null and !#strings.isEmpty(block.data['tagFilter']) and #strings.containsIgnoreCase(block.data['tagFilter'], p.tagNamesString))
)}">
<a th:href="@{'/posts/' + ${p.slug}}"
class="news-card"
th:data-category="${p.category != null ? p.category.name : ''}"
th:data-tags="${p.tags != null ? #strings.arrayJoin(p.tags.![name], ',') : ''}">
th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
<div class="card-thumb">
<img th:src="${p.featuredImage}" th:alt="${p.title}" />
<span class="year-badge" th:if="${p.createdDate != null}" th:text="${#temporals.format(p.createdDate, 'yyyy')}">2026</span>
@@ -224,28 +233,25 @@
if (!grid) return;
const cards = Array.from(grid.querySelectorAll('.news-card'));
const itemsPerPage = parseInt(grid.getAttribute('data-items-per-page')) || 9;
const itemsPerPage = Math.min(parseInt(grid.getAttribute('data-items-per-page')) || 12, 12);
let currentPage = 1;
let activeFilter = 'ALL';
// Render Tabs from posts.js multi-choice selection or extracted cards
if (tabsContainer) {
const rawConfigured = tabsContainer.getAttribute('data-configured-tabs') || '';
let tabNames = rawConfigured.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
// Fallback to extracting from cards if no specific multi-choice selection was made
if (tabNames.length === 0) {
const foundSet = new Set();
cards.forEach(function (card) {
const cat = card.getAttribute('data-category');
if (cat) foundSet.add(cat.trim());
if (cat && cat.trim()) foundSet.add(cat.trim());
const tags = (card.getAttribute('data-tags') || '').split(',');
tags.forEach(function (t) { if (t.trim()) foundSet.add(t.trim()); });
});
tabNames = Array.from(foundSet);
}
// Build tab button elements
tabNames.forEach(function (name) {
const btn = document.createElement('button');
btn.type = 'button';
@@ -255,7 +261,6 @@
tabsContainer.appendChild(btn);
});
// Single active tab switching
tabsContainer.addEventListener('click', function (e) {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
@@ -274,29 +279,40 @@
});
}
// Render card visibility based on active tab & calculate pagination
function render() {
let visibleCards = cards.filter(function (card) {
if (activeFilter === 'ALL') return true;
const cardCat = (card.getAttribute('data-category') || '').trim();
const cardTags = (card.getAttribute('data-tags') || '').split(',').map(function (s) { return s.trim(); });
return cardCat === activeFilter || cardTags.includes(activeFilter);
const targetFilter = activeFilter.trim().toLowerCase();
const cardCat = (card.getAttribute('data-category') || '').trim().toLowerCase();
const cardTags = (card.getAttribute('data-tags') || '').split(',').map(function (s) { return s.trim().toLowerCase(); });
return cardCat === targetFilter || cardTags.includes(targetFilter);
});
const totalPages = Math.ceil(visibleCards.length / itemsPerPage) || 1;
if (currentPage > totalPages) currentPage = totalPages;
// Hide all
cards.forEach(function (c) { c.style.display = 'none'; });
// Show current page slice
let noPostsMsg = grid.querySelector('.no-posts-found');
if (visibleCards.length === 0) {
if (!noPostsMsg) {
noPostsMsg = document.createElement('div');
noPostsMsg.className = 'no-posts-found';
noPostsMsg.style.cssText = 'grid-column: 1 / -1; text-align: center; color: #64748b; padding: 40px 20px; font-size: 15px; background: #f8fafc; border-radius: 8px; border: 1px dashed #cbd5e1; margin: 20px 0;';
noPostsMsg.textContent = 'Chưa có bài viết nào thuộc danh mục/thẻ này.';
grid.appendChild(noPostsMsg);
}
noPostsMsg.style.display = 'block';
} else {
if (noPostsMsg) noPostsMsg.style.display = 'none';
}
const startIdx = (currentPage - 1) * itemsPerPage;
const endIdx = startIdx + itemsPerPage;
visibleCards.slice(startIdx, endIdx).forEach(function (c) {
c.style.display = 'flex';
});
// Render Pagination
if (pagination) {
pagination.innerHTML = '';
if (totalPages <= 1) return;
@@ -62,7 +62,7 @@ public class SecurityConfiguration {
authz
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
"/manage/login", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**", "/UMass*/**", "/Undergraduate*/**",
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/upload/**", "/api/manage/snippets/**", "/api/categories", "/api/tags", "/page/**", "/pages/**", "/news/article/**", "/post/**", "/error",
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/upload/**", "/api/manage/snippets/**", "/api/categories", "/api/tags", "/page/**", "/pages/**", "/news/article/**", "/post/**", "/posts", "/posts/**", "/error",
"/about-us", "/specialty", "/doctor", "/bac-si/**", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**", "/dao-tao/**", "/chuyen-khoa", "/chuyen-khoa/**",
"/Đào tạo tại UMC_files/**", "/dat-lich", "/dat-lich/**", "/api/public/doctor-schedule/**")
.permitAll()
@@ -2,7 +2,9 @@ package com.sisvietnamvn.web.domain;
import java.io.Serial;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -101,6 +103,20 @@ public class Post extends AbstractAuditingEntity<Long> {
)
private Set<Tag> tags = new HashSet<>();
public String getCategoryName() {
return category != null ? category.getName() : "";
}
public String getTagNamesString() {
if (tags == null || tags.isEmpty()) {
return "";
}
return tags.stream()
.map(Tag::getName)
.filter(Objects::nonNull)
.collect(Collectors.joining(","));
}
// --- Getters and Setters ---
@Override
@@ -29,7 +29,7 @@ class SISPostsTool {
statusFilter: data && data.statusFilter ? data.statusFilter : 'PUBLISHED',
displayStyle: data && data.displayStyle ? data.displayStyle : 'grid', // 'grid', 'list', 'cards', 'compact'
cols: parseInt(data && data.cols) || 3,
limit: parseInt(data && data.limit) || 6,
limit: parseInt(data && data.limit) || 12,
orderBy: data && data.orderBy ? data.orderBy : 'createdDate_desc',
locationFilter: data && data.locationFilter ? data.locationFilter : '',
postIds: data && data.postIds ? data.postIds : '', // Optional manually comma-separated IDs
@@ -37,7 +37,7 @@ class SISPostsTool {
showExcerpt: data && data.showExcerpt !== undefined ? !!data.showExcerpt : true,
showDate: data && data.showDate !== undefined ? !!data.showDate : true,
showLocation: data && data.showLocation !== undefined ? !!data.showLocation : true,
itemsPerPage: parseInt(data && data.itemsPerPage) || parseInt(data && data.limit) || 9,
itemsPerPage: parseInt(data && data.itemsPerPage) || parseInt(data && data.limit) || 12,
enablePagination: data && data.enablePagination !== undefined ? !!data.enablePagination : true,
enableTabs: data && data.enableTabs !== undefined ? !!data.enableTabs : true,
globalId: data && data.globalId ? data.globalId : '',
@@ -114,7 +114,7 @@ class SISPostsTool {
// 3. Limit (Number of posts)
const limitDiv = document.createElement('div');
limitDiv.className = 'col-md-2 mb-2';
limitDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Limit (Count)</label>';
limitDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Limit Posts (12 = 4 rows)</label>';
const limitInput = document.createElement('input');
limitInput.type = 'number';
limitInput.className = 'form-control form-control-sm';
@@ -122,7 +122,11 @@ class SISPostsTool {
limitInput.min = 1;
limitInput.max = 50;
if (this.readOnly) limitInput.disabled = true;
limitInput.addEventListener('input', (e) => this.data.limit = parseInt(e.target.value) || 6);
limitInput.addEventListener('input', (e) => {
const val = parseInt(e.target.value) || 12;
this.data.limit = val;
this.data.itemsPerPage = val;
});
limitDiv.appendChild(limitInput);
formRow1.appendChild(limitDiv);
@@ -621,12 +621,21 @@
</div>
<!-- 2. CARD GRID -->
<div class="news-grid" th:data-items-per-page="${block.data['itemsPerPage'] != null ? block.data['itemsPerPage'] : (block.data['limit'] != null ? block.data['limit'] : 9)}">
<th:block th:each="p : ${posts}" th:if="${(block.data['layoutFilter'] == null or block.data['layoutFilter'] == 'ALL' or (p.layout != null and #strings.equals(p.layout.name(), block.data['layoutFilter'])))}">
<div class="news-grid" th:data-items-per-page="${block.data['limit'] != null and block.data['limit'] > 0 ? block.data['limit'] : 12}">
<th:block th:each="p : ${posts}"
th:if="${(
(block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or #strings.contains(block.data['categoryFilter'], 'ALL'))
and
(block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or #strings.contains(block.data['tagFilter'], 'ALL'))
) or (
(p.category != null and block.data['categoryFilter'] != null and !#strings.isEmpty(block.data['categoryFilter']) and #strings.containsIgnoreCase(block.data['categoryFilter'], p.category.name))
or
(p.tags != null and block.data['tagFilter'] != null and !#strings.isEmpty(block.data['tagFilter']) and #strings.containsIgnoreCase(block.data['tagFilter'], p.tagNamesString))
)}">
<a th:href="@{'/posts/' + ${p.slug}}"
class="news-card"
th:data-category="${p.category != null ? p.category.name : ''}"
th:data-tags="${p.tags != null ? #strings.arrayJoin(p.tags.![name], ',') : ''}">
th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
<div class="card-thumb">
<img th:src="${p.featuredImage}" th:alt="${p.title}" />
<span class="year-badge" th:if="${p.createdDate != null}" th:text="${#temporals.format(p.createdDate, 'yyyy')}">2026</span>
@@ -656,7 +665,7 @@
if (!grid) return;
const cards = Array.from(grid.querySelectorAll('.news-card'));
const itemsPerPage = parseInt(grid.getAttribute('data-items-per-page')) || 9;
const itemsPerPage = Math.min(parseInt(grid.getAttribute('data-items-per-page')) || 12, 12);
let currentPage = 1;
let activeFilter = 'ALL';
@@ -668,7 +677,7 @@
const foundSet = new Set();
cards.forEach(function (card) {
const cat = card.getAttribute('data-category');
if (cat) foundSet.add(cat.trim());
if (cat && cat.trim()) foundSet.add(cat.trim());
const tags = (card.getAttribute('data-tags') || '').split(',');
tags.forEach(function (t) { if (t.trim()) foundSet.add(t.trim()); });
});
@@ -705,9 +714,10 @@
function render() {
let visibleCards = cards.filter(function (card) {
if (activeFilter === 'ALL') return true;
const cardCat = (card.getAttribute('data-category') || '').trim();
const cardTags = (card.getAttribute('data-tags') || '').split(',').map(function (s) { return s.trim(); });
return cardCat === activeFilter || cardTags.includes(activeFilter);
const targetFilter = activeFilter.trim().toLowerCase();
const cardCat = (card.getAttribute('data-category') || '').trim().toLowerCase();
const cardTags = (card.getAttribute('data-tags') || '').split(',').map(function (s) { return s.trim().toLowerCase(); });
return cardCat === targetFilter || cardTags.includes(targetFilter);
});
const totalPages = Math.ceil(visibleCards.length / itemsPerPage) || 1;
@@ -715,6 +725,20 @@
cards.forEach(function (c) { c.style.display = 'none'; });
let noPostsMsg = grid.querySelector('.no-posts-found');
if (visibleCards.length === 0) {
if (!noPostsMsg) {
noPostsMsg = document.createElement('div');
noPostsMsg.className = 'no-posts-found';
noPostsMsg.style.cssText = 'grid-column: 1 / -1; text-align: center; color: #64748b; padding: 40px 20px; font-size: 15px; background: #f8fafc; border-radius: 8px; border: 1px dashed #cbd5e1; margin: 20px 0;';
noPostsMsg.textContent = 'Chưa có bài viết nào thuộc danh mục/thẻ này.';
grid.appendChild(noPostsMsg);
}
noPostsMsg.style.display = 'block';
} else {
if (noPostsMsg) noPostsMsg.style.display = 'none';
}
const startIdx = (currentPage - 1) * itemsPerPage;
const endIdx = startIdx + itemsPerPage;
visibleCards.slice(startIdx, endIdx).forEach(function (c) {