refactor: update card grid filtering and pagination logic to support category/tag filters and display empty states
This commit is contained in:
@@ -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;
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user