feat: implement Bento layout support and Editor.js integration for dynamic post lists

This commit is contained in:
2026-08-03 20:00:07 +07:00
parent 48a2cd02f1
commit 141d3a71fd
112 changed files with 4005 additions and 186 deletions
@@ -0,0 +1,177 @@
<style>
/* Bento Layout 01 Design System */
.bento-gallery {
display: grid !important;
grid-template-columns: repeat(12, 1fr) !important;
grid-template-rows: repeat(2, 220px);
gap: 16px;
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.bento-item {
position: relative;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
background-color: #f1f5f9;
}
.bento-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.bento-item:hover img {
transform: scale(1.05);
}
.bento-item::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.25) 55%, transparent 100%);
pointer-events: none;
transition: opacity 0.3s ease;
}
.bento-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
z-index: 2;
color: #ffffff;
}
.bento-content h2,
.bento-content h3 {
margin: 0;
color: #ffffff;
font-weight: 600;
line-height: 1.35;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
.bento-content h3 {
font-size: 0.925rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bento-content h2 {
font-size: 1.15rem;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Bento Column Assignments */
.left-top,
.left-bottom {
grid-column: span 3 !important;
}
.center-featured {
grid-column: span 6 !important;
grid-row: span 2 !important;
}
.right-top,
.right-bottom {
grid-column: span 3 !important;
}
/* Responsive Adjustments */
@media (max-width: 992px) {
.bento-gallery {
grid-template-rows: auto;
}
.center-featured {
grid-column: span 12 !important;
grid-row: span 1 !important;
height: 280px;
}
.left-top,
.left-bottom,
.right-top,
.right-bottom {
grid-column: span 6 !important;
height: 180px;
}
}
@media (max-width: 576px) {
.left-top,
.left-bottom,
.right-top,
.right-bottom {
grid-column: span 12 !important;
height: 160px;
}
}
</style>
<div class="bento-gallery" th:if="${posts != null}">
<th:block
th:with="filteredPosts=${posts.?[ (block.data['layoutFilter'] == null or block.data['layoutFilter'] == 'ALL' or (layout != null and #strings.equals(layout.name(), block.data['layoutFilter']))) and (block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or (category != null and #strings.containsIgnoreCase(category.name, block.data['categoryFilter']))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (tags != null and #lists.contains(tags.![name], block.data['tagFilter']))) ]}"
>
<!-- 1. Ô trên bên trái -->
<div class="bento-item left-top" th:if="${#lists.size(filteredPosts) > 0}" th:with="p=${filteredPosts[0]}">
<a th:href="@{'/posts/' + ${p.slug}}" class="text-decoration-none text-white d-block h-100 w-100">
<img th:src="${p.featuredImage}" th:alt="${p.title}" />
<div class="bento-content">
<h3 th:utext="${p.title}">Post Title 1</h3>
</div>
</a>
</div>
<!-- 2. Ô dưới bên trái -->
<div class="bento-item left-bottom" th:if="${#lists.size(filteredPosts) > 1}" th:with="p=${filteredPosts[1]}">
<a th:href="@{'/posts/' + ${p.slug}}" class="text-decoration-none text-white d-block h-100 w-100">
<img th:src="${p.featuredImage}" th:alt="${p.title}" />
<div class="bento-content">
<h3 th:utext="${p.title}">Post Title 2</h3>
</div>
</a>
</div>
<!-- 3. Ô chính giữa (Hình lớn nổi bật) -->
<div class="bento-item center-featured" th:if="${#lists.size(filteredPosts) > 2}" th:with="p=${filteredPosts[2]}">
<a th:href="@{'/posts/' + ${p.slug}}" class="text-decoration-none text-white d-block h-100 w-100">
<img th:src="${p.featuredImage}" th:alt="${p.title}" />
<div class="bento-content">
<h2 th:utext="${p.title}">Featured Post Title</h2>
</div>
</a>
</div>
<!-- 4. Ô trên bên phải -->
<div class="bento-item right-top" th:if="${#lists.size(filteredPosts) > 3}" th:with="p=${filteredPosts[3]}">
<a th:href="@{'/posts/' + ${p.slug}}" class="text-decoration-none text-white d-block h-100 w-100">
<img th:src="${p.featuredImage}" th:alt="${p.title}" />
<div class="bento-content">
<h3 th:utext="${p.title}">Post Title 4</h3>
</div>
</a>
</div>
<!-- 5. Ô dưới bên phải -->
<div class="bento-item right-bottom" th:if="${#lists.size(filteredPosts) > 4}" th:with="p=${filteredPosts[4]}">
<a th:href="@{'/posts/' + ${p.slug}}" class="text-decoration-none text-white d-block h-100 w-100">
<img th:src="${p.featuredImage}" th:alt="${p.title}" />
<div class="bento-content">
<h3 th:utext="${p.title}">Post Title 5</h3>
</div>
</a>
</div>
</th:block>
</div>
@@ -0,0 +1,343 @@
<style>
/* ===================================================
CARD GRID LAYOUT DESIGN SYSTEM & STYLES
=================================================== */
.news-container {
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* FILTER TABS */
.filter-tabs {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 30px;
}
.tab-btn {
padding: 8px 18px;
border: none;
background-color: #e2e8f0;
color: #475569;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.tab-btn:hover {
background-color: #cbd5e1;
}
.tab-btn.active {
background-color: #0284c7;
color: #ffffff;
}
/* CARD GRID */
.news-grid {
display: grid !important;
grid-template-columns: repeat(3, 1fr) !important;
gap: 24px;
margin-bottom: 40px;
width: 100%;
}
.news-card {
background-color: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
transition: transform 0.25s ease, box-shadow 0.25s ease;
cursor: pointer;
border: 1px solid #f1f5f9;
text-decoration: none !important;
}
.news-card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.card-thumb {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background-color: #e2e8f0;
}
.card-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
.news-card:hover .card-thumb img {
transform: scale(1.05);
}
.year-badge {
position: absolute;
top: 10px;
right: 10px;
background-color: #0284c7;
color: #ffffff;
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.card-body {
padding: 16px 18px 20px;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.card-title {
font-size: 14px;
font-weight: 700;
color: #0284c7;
line-height: 1.45;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* PAGINATION */
.pagination {
display: flex;
justify-content: center;
align-items: center;
list-style: none;
gap: 8px;
margin-top: 20px;
padding: 0;
}
.page-link {
display: inline-flex;
justify-content: center;
align-items: center;
min-width: 36px;
height: 36px;
padding: 0 10px;
border-radius: 50%;
border: 1px solid #e2e8f0;
background-color: #ffffff;
color: #64748b;
text-decoration: none !important;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
user-select: none;
}
.page-link:hover {
border-color: #0284c7;
color: #0284c7;
}
.page-link.active {
background-color: #0284c7;
color: #ffffff;
border-color: #0284c7;
}
.page-dots {
padding: 0 4px;
color: #94a3b8;
}
/* RESPONSIVE */
@media (max-width: 992px) {
.news-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
}
@media (max-width: 576px) {
.news-grid {
grid-template-columns: 1fr !important;
}
.filter-tabs {
justify-content: flex-start;
}
}
</style>
<div class="news-container" th:if="${posts != null}">
<!-- 1. FILTER TABS (Every selected Category/Tag in posts.js becomes a Tab) -->
<div class="filter-tabs"
th:if="${block.data['enableTabs'] == null or block.data['enableTabs'] == true}"
th:data-configured-tabs="${(block.data['categoryFilter'] != null ? block.data['categoryFilter'] : '') + (block.data['tagFilter'] != null and !#strings.isEmpty(block.data['tagFilter']) ? (block.data['categoryFilter'] != null and !#strings.isEmpty(block.data['categoryFilter']) ? ',' : '') + block.data['tagFilter'] : '')}">
<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'])))}">
<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], ',') : ''}">
<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>
</div>
<div class="card-body">
<h3 class="card-title" th:utext="${p.title}">Post Title</h3>
</div>
</a>
</th:block>
</div>
<!-- 3. PAGINATION -->
<ul class="pagination" th:if="${block.data['enablePagination'] == null or block.data['enablePagination'] == true}"></ul>
</div>
<script>
(function () {
function initCardGridContainers() {
const containers = document.querySelectorAll('.news-container');
containers.forEach(function (container) {
if (container.dataset.gridInitialized) return;
container.dataset.gridInitialized = 'true';
const tabsContainer = container.querySelector('.filter-tabs');
const grid = container.querySelector('.news-grid');
const pagination = container.querySelector('.pagination');
if (!grid) return;
const cards = Array.from(grid.querySelectorAll('.news-card'));
const itemsPerPage = parseInt(grid.getAttribute('data-items-per-page')) || 9;
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());
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';
btn.className = 'tab-btn';
btn.setAttribute('data-filter', name);
btn.textContent = name;
tabsContainer.appendChild(btn);
});
// Single active tab switching
tabsContainer.addEventListener('click', function (e) {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
activeFilter = btn.getAttribute('data-filter');
tabsContainer.querySelectorAll('.tab-btn').forEach(function (b) {
if (b.getAttribute('data-filter') === activeFilter) {
b.classList.add('active');
} else {
b.classList.remove('active');
}
});
currentPage = 1;
render();
});
}
// 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 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
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;
const prevLi = document.createElement('li');
prevLi.innerHTML = '<span class="page-link">&laquo;</span>';
prevLi.addEventListener('click', function () {
if (currentPage > 1) { currentPage--; render(); }
});
pagination.appendChild(prevLi);
for (let i = 1; i <= totalPages; i++) {
const li = document.createElement('li');
const a = document.createElement('span');
a.className = 'page-link' + (i === currentPage ? ' active' : '');
a.textContent = i;
a.addEventListener('click', function () {
currentPage = i;
render();
});
li.appendChild(a);
pagination.appendChild(li);
}
const nextLi = document.createElement('li');
nextLi.innerHTML = '<span class="page-link">&raquo;</span>';
nextLi.addEventListener('click', function () {
if (currentPage < totalPages) { currentPage++; render(); }
});
pagination.appendChild(nextLi);
}
}
render();
});
}
if (document.readyState === 'interactive' || document.readyState === 'complete') {
initCardGridContainers();
} else {
document.addEventListener('DOMContentLoaded', initCardGridContainers);
}
})();
</script>
@@ -1,155 +0,0 @@
{
"time": 1785560196216,
"blocks": [
{
"id": "HEo3OKYI07",
"type": "stickyNav",
"data": {
"title": "Tầm nhìn - Sứ mệnh",
"bgColor": "#ffffff",
"items": [
{
"label": "Giới thiệu tổng quan",
"link": "/page/gioi-thieu-tong-quan"
},
{
"label": "Lịch sử hình thành",
"link": "/page/lich-su-hinh-thanh"
},
{
"label": "Tầm nhìn - Sứ mệnh - Giá trị cốt lõi",
"link": "/page/tam-nhin-su-menh-gia-tri-cot-loi"
},
{
"label": "Ban lãnh đạo",
"link": "/page/doi-ngu-lanh-dao"
},
{
"label": "Các đơn vị",
"link": "/page/cac-don-vi"
},
{
"label": "Thành tựu và giải thưởng",
"link": "/page/thanh-tuu-va-giai-thuong"
}
],
"sticky": true,
"stretched": true
}
},
{
"id": "_9-7doWIJA",
"type": "grid",
"data": {
"cols": 2,
"globalClass": "",
"globalId": "",
"type1": "html",
"col1": "\n<div class=\"relative pt-4\">\n <div class=\"absolute md:-left-4 -left-3 md:-top-5 -top-2 md:size-12 size-8 bg-primary-100 rounded-full\"></div>\n <h2 class=\"display-3 text-primary-500 uppercase mb-4 block z-[1] relative\">TẦM NHÌN</h2>\n <div class=\"flex flex-col space-y-4\">\n <div class=\"bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow\">\n <p class=\"body-1 text-gray-800 text-justify !font-medium\">Trở thành bệnh viện đại học dẫn đầu Việt Nam đạt chuẩn quốc tế</p>\n </div>\n </div>\n</div>\n",
"imgUrl1": "",
"ytUrl1": "",
"accTitle1": "",
"accContent1": "",
"class1": "relative z-10",
"id1": "",
"width1": 6,
"type2": "image",
"col2": {
"file": {
"extension": "webp",
"size": 98106,
"name": "dotquy203.webp",
"url": "/uploads/2026/08/dotquy203.webp"
},
"caption": "",
"withBorder": false,
"stretched": false,
"withBackground": false
},
"imgUrl2": "",
"ytUrl2": "",
"accTitle2": "",
"accContent2": "",
"class2": "shadow-lg rounded",
"id2": "",
"width2": 6
}
},
{
"id": "SL3DLP54-m",
"type": "grid",
"data": {
"cols": 2,
"globalClass": "",
"globalId": "",
"type1": "image",
"col1": {
"file": {
"url": "/uploads/2026/07/dotquy201.webp"
},
"caption": "",
"withBorder": false,
"stretched": false,
"withBackground": false
},
"imgUrl1": "",
"ytUrl1": "",
"accTitle1": "",
"accContent1": "",
"class1": "shadow-lg rounded",
"id1": "",
"width1": 6,
"type2": "html",
"col2": "\n<div class=\"relative pt-4\">\n <div class=\"absolute md:-left-5 -left-4 md:-top-5 -top-2 md:size-12 size-8 bg-primary-100 rounded-full\"></div>\n <h2 class=\"display-3 text-primary-500 mb-4 relative z-[1]\">SỨ MỆNH</h2>\n <div class=\"bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow\">\n <p class=\"body-1 text-primary-500 text-justify !font-medium\">Mang đến giải pháp chăm sóc sức khỏe tối ưu từ sự tích hợp giữa điều trị, nghiên cứu và đào tạo</p>\n </div>\n</div>\n",
"imgUrl2": "",
"ytUrl2": "",
"accTitle2": "",
"accContent2": "",
"class2": "",
"id2": "",
"width2": 6
}
},
{
"id": "bJ3ycvyeZT",
"type": "hovercard",
"data": {
"cols": 4,
"styleVariant": "v2",
"globalClass": "sis-hover-card-v2-section",
"globalId": "",
"items": [
{
"title": "Tiên phong",
"description": "Phát triển và ứng dụng kỹ thuật tiên tiến vào điều trị, giảng dạy, nghiên cứu",
"imageUrl": "/uploads/2026/08/core-1.webp",
"hoverImageUrl": "/uploads/2026/08/core-hover-1.webp",
"active": true
},
{
"title": "Thấu hiểu",
"description": "Nỗi đau thể xác và tinh thần của người bệnh để đưa ra giải pháp điều trị tối ưu",
"imageUrl": "/uploads/2026/08/core-2.webp",
"hoverImageUrl": "/uploads/2026/08/core-hover-2.webp",
"active": false
},
{
"title": "Chuẩn mực",
"description": "Về y đức và học thuật của người thầy giáo thầy thuốc",
"imageUrl": "/uploads/2026/08/core-3.webp",
"hoverImageUrl": "/uploads/2026/08/core-hover-3.webp",
"active": false
},
{
"title": "An toàn",
"description": "Cho người bệnh, nhân viên y tế và cộng đồng",
"imageUrl": "/uploads/2026/08/core-4.webp",
"hoverImageUrl": "/uploads/2026/08/core-hover-4.webp",
"active": false
}
]
}
}
],
"version": "2.31.0-rc.7"
}
@@ -1,80 +0,0 @@
.sis-hover-card-grid {
padding-top: 30px;
padding-bottom: 30px;
}
.sis-hover-card-grid .row {
display: flex !important;
align-items: stretch !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"] {
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) !important;
cursor: pointer;
background-color: #ffffff !important;
border: 1px solid #e6f0fa !important;
border-radius: 12px !important;
box-shadow: 0 4px 15px rgba(0, 37, 84, 0.08) !important;
padding: 30px 20px !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: flex-start !important;
min-height: 260px;
text-align: center;
position: relative;
overflow: hidden;
}
.sis-hover-card-grid .row > [class*=\"col-\"] img {
height: 80px !important;
width: auto !important;
max-width: 100% !important;
object-fit: contain !important;
margin-bottom: 16px !important;
transition:
filter 0.4s ease,
transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"] h3 {
color: #0d71ba !important;
font-size: 1.25rem !important;
font-weight: 700 !important;
margin-bottom: 0 !important;
transition: color 0.4s ease !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"] p {
color: rgba(255, 255, 255, 0) !important;
font-size: 0.9rem !important;
line-height: 1.5 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
max-height: 0;
opacity: 0;
overflow: hidden;
transition:
max-height 0.4s ease,
opacity 0.4s ease,
color 0.4s ease,
margin-top 0.4s ease !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"]:hover,
.sis-hover-card-grid .row > [class*=\"col-\"].active {
background-color: #0d71ba !important;
border-color: #0d71ba !important;
transform: translateY(-8px) !important;
box-shadow: 0 12px 25px rgba(13, 113, 186, 0.3) !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"]:hover h3,
.sis-hover-card-grid .row > [class*=\"col-\"].active h3 {
color: #ffffff !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"]:hover p,
.sis-hover-card-grid .row > [class*=\"col-\"].active p {
color: #ffffff !important;
opacity: 1 !important;
max-height: 150px;
margin-top: 12px !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"]:hover img,
.sis-hover-card-grid .row > [class*=\"col-\"].active img {
/* filter: brightness(0) invert(1) !important; */
transform: scale(1.05) !important;
}
@@ -1,11 +0,0 @@
document.addEventListener('DOMContentLoaded', function () {
var cards = document.querySelectorAll('.sis-hover-card-grid .row > [class*=\"col-\"]');
if (cards.length > 0) {
cards.forEach(function (card) {
card.addEventListener('click', function () {
cards.forEach(function (c) { c.classList.remove('active'); });
card.classList.add('active');
});
});
}
});
@@ -1,5 +0,0 @@
<section class="sis-hover-card-v2-section py-5">
<div class="container">
<h2 class="text-center display-4 text-primary-600 xl:mb-6 md:mb-4 mb-3 max-md:px-4 uppercase font-weight-bold">GIÁ TRỊ CỐT LÕI</h2>
</div>
</section>
File diff suppressed because one or more lines are too long