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
@@ -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/**", "/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/**", "/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()
@@ -461,7 +461,15 @@ public class PageController {
model.addAttribute("page", page);
model.addAttribute("blocks", blocks);
// If the page contains a 'posts' block, load published posts so the template can render them
boolean hasPostsBlock = blocks.stream().anyMatch(b -> "posts".equals(b.get("type")));
if (hasPostsBlock) {
List<Post> publishedPosts = postRepository.findByStatusOrderByCreatedDateDesc(PageStatus.PUBLISHED);
model.addAttribute("posts", publishedPosts);
LOG.debug("Page '{}' has posts block — loaded {} published posts", page.getSlug(), publishedPosts.size());
}
if (com.sisvietnamvn.web.domain.PageType.CONTACT_US.equals(page.getPageType())) {
return "pages/contact-us";
}
@@ -0,0 +1,49 @@
package com.sisvietnamvn.web.controller.api;
import com.sisvietnamvn.web.domain.Category;
import com.sisvietnamvn.web.domain.Tag;
import com.sisvietnamvn.web.service.CategoryService;
import com.sisvietnamvn.web.service.TagService;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for serving system categories and tags to front-end and editor plugins.
*/
@RestController
@RequestMapping("/api")
public class CategoryTagApiController {
private static final Logger LOG = LoggerFactory.getLogger(CategoryTagApiController.class);
private final CategoryService categoryService;
private final TagService tagService;
public CategoryTagApiController(CategoryService categoryService, TagService tagService) {
this.categoryService = categoryService;
this.tagService = tagService;
}
/**
* GET /api/categories : Get all categories ordered by name.
*/
@GetMapping("/categories")
public ResponseEntity<List<Category>> getAllCategories() {
LOG.debug("REST request to get all Categories");
return ResponseEntity.ok(categoryService.findAll());
}
/**
* GET /api/tags : Get all tags ordered by name.
*/
@GetMapping("/tags")
public ResponseEntity<List<Tag>> getAllTags() {
LOG.debug("REST request to get all Tags");
return ResponseEntity.ok(tagService.findAll());
}
}
@@ -35,10 +35,24 @@ public class ManagePageController {
private final PageService pageService;
private final HookManager hookManager;
private final com.sisvietnamvn.web.service.CategoryService categoryService;
private final com.sisvietnamvn.web.service.TagService tagService;
public ManagePageController(PageService pageService, HookManager hookManager) {
public ManagePageController(PageService pageService, HookManager hookManager,
com.sisvietnamvn.web.service.CategoryService categoryService,
com.sisvietnamvn.web.service.TagService tagService) {
this.pageService = pageService;
this.hookManager = hookManager;
this.categoryService = categoryService;
this.tagService = tagService;
}
private void populateFormModel(Model model) {
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("pageTypes", com.sisvietnamvn.web.domain.PageType.values());
model.addAttribute("layouts", com.sisvietnamvn.web.domain.PageLayout.values());
model.addAttribute("categories", categoryService.findAll());
model.addAttribute("tags", tagService.findAll());
}
/**
@@ -69,9 +83,7 @@ public class ManagePageController {
page.setStatus(PageStatus.DRAFT);
page.setDisplayOrder(0);
model.addAttribute("page", page);
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("pageTypes", com.sisvietnamvn.web.domain.PageType.values());
model.addAttribute("layouts", com.sisvietnamvn.web.domain.PageLayout.values());
populateFormModel(model);
model.addAttribute("isNew", true);
return "manage/pages/form";
}
@@ -86,9 +98,7 @@ public class ManagePageController {
RedirectAttributes redirectAttributes) {
LOG.debug("Request to create Page : {}", page);
if (bindingResult.hasErrors()) {
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("pageTypes", com.sisvietnamvn.web.domain.PageType.values());
model.addAttribute("layouts", com.sisvietnamvn.web.domain.PageLayout.values());
populateFormModel(model);
model.addAttribute("isNew", true);
return "manage/pages/form";
}
@@ -109,9 +119,7 @@ public class ManagePageController {
return "redirect:/manage/pages";
}
model.addAttribute("page", pageOptional.get());
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("pageTypes", com.sisvietnamvn.web.domain.PageType.values());
model.addAttribute("layouts", com.sisvietnamvn.web.domain.PageLayout.values());
populateFormModel(model);
model.addAttribute("isNew", false);
return "manage/pages/form";
}
@@ -127,9 +135,7 @@ public class ManagePageController {
RedirectAttributes redirectAttributes) {
LOG.debug("Request to update Page : {}", id);
if (bindingResult.hasErrors()) {
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("pageTypes", com.sisvietnamvn.web.domain.PageType.values());
model.addAttribute("layouts", com.sisvietnamvn.web.domain.PageLayout.values());
populateFormModel(model);
model.addAttribute("isNew", false);
return "manage/pages/form";
}
@@ -1,5 +1,6 @@
package com.sisvietnamvn.web.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -37,6 +38,7 @@ public class Category extends AbstractAuditingEntity<Long> {
@Column(name = "description", length = 1000)
private String description;
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
@@ -0,0 +1,425 @@
/**
* SISPostsTool — Dynamic Posts List Block Tool for Editor.js
* Allows content managers to select and display a list of posts filtered by specific attributes:
* - Category / Post Type
* - Post Layout (STANDARD, FEATURED, GRID, HERO, EVENT)
* - Post Status (PUBLISHED, DRAFT)
* - Custom Location / Event Time attribute filtering
* - Display Style (Grid, List, Cards, Compact)
* - Limit & Ordering
* - Option to select specific Post IDs manually
* - HTML ID, CSS Class, Custom Attributes, and Dedicated Style Attributes
*/
class SISPostsTool {
static get toolbox() {
return {
title: 'Posts List',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
category: data && data.category ? data.category : 'ALL',
categoryFilter: data && data.categoryFilter ? data.categoryFilter : '',
tagFilter: data && data.tagFilter ? data.tagFilter : '',
layoutFilter: data && data.layoutFilter ? data.layoutFilter : 'ALL',
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,
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
showImage: data && data.showImage !== undefined ? !!data.showImage : true,
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,
enablePagination: data && data.enablePagination !== undefined ? !!data.enablePagination : true,
enableTabs: data && data.enableTabs !== undefined ? !!data.enableTabs : true,
globalId: data && data.globalId ? data.globalId : '',
globalClass: data && data.globalClass ? data.globalClass : 'sis-posts-block',
globalStyle: data && data.globalStyle ? data.globalStyle : '',
globalAttributes: data && data.globalAttributes ? data.globalAttributes : (data && data.attributes) || '',
customCss: data && data.customCss ? data.customCss : '',
customJs: data && data.customJs ? data.customJs : ''
};
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-posts-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
this.wrapper.style.borderLeft = '4px solid #4e73df';
const headerDiv = document.createElement('div');
headerDiv.className = 'font-weight-bold text-primary small mb-3 d-flex justify-content-between align-items-center';
headerDiv.innerHTML = '<span><i class="fas fa-newspaper mr-1"></i> Posts List Settings & Attribute Filtering</span><span class="badge badge-primary px-2 py-1">POSTS PLUGIN</span>';
this.wrapper.appendChild(headerDiv);
// Row 1: Content & Layout Filtering
const formRow1 = document.createElement('div');
formRow1.className = 'form-row mb-3 pb-2 border-bottom';
// 1. Display Style
const styleDiv = document.createElement('div');
styleDiv.className = 'col-md-3 mb-2';
styleDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Display Style</label>';
const styleSelect = document.createElement('select');
styleSelect.className = 'form-control form-control-sm';
const styleOpts = [
{ val: 'grid', lbl: 'Grid Cards (Default)' },
{ val: 'bento_layout_01', lbl: 'Bento Layout 01' },
{ val: 'card_grid_layout', lbl: 'Card Grid Layout' },
{ val: 'list', lbl: 'Horizontal List' },
{ val: 'cards', lbl: 'Featured Large Cards' },
{ val: 'compact', lbl: 'Compact Headline List' }
];
styleOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.textContent = o.lbl;
if (this.data.displayStyle === o.val) opt.selected = true;
styleSelect.appendChild(opt);
});
if (this.readOnly) styleSelect.disabled = true;
styleSelect.addEventListener('change', (e) => this.data.displayStyle = e.target.value);
styleDiv.appendChild(styleSelect);
formRow1.appendChild(styleDiv);
// 2. Columns Count
const colDiv = document.createElement('div');
colDiv.className = 'col-md-2 mb-2';
colDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Columns</label>';
const colSelect = document.createElement('select');
colSelect.className = 'form-control form-control-sm';
[1, 2, 3, 4, 6].forEach(num => {
const opt = document.createElement('option');
opt.value = num;
opt.textContent = num + ' Column' + (num > 1 ? 's' : '');
if (this.data.cols === num) opt.selected = true;
colSelect.appendChild(opt);
});
if (this.readOnly) colSelect.disabled = true;
colSelect.addEventListener('change', (e) => this.data.cols = parseInt(e.target.value));
colDiv.appendChild(colSelect);
formRow1.appendChild(colDiv);
// 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>';
const limitInput = document.createElement('input');
limitInput.type = 'number';
limitInput.className = 'form-control form-control-sm';
limitInput.value = this.data.limit;
limitInput.min = 1;
limitInput.max = 50;
if (this.readOnly) limitInput.disabled = true;
limitInput.addEventListener('input', (e) => this.data.limit = parseInt(e.target.value) || 6);
limitDiv.appendChild(limitInput);
formRow1.appendChild(limitDiv);
// 4. Order By
const orderDiv = document.createElement('div');
orderDiv.className = 'col-md-2 mb-2';
orderDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Order By</label>';
const orderSelect = document.createElement('select');
orderSelect.className = 'form-control form-control-sm';
const orderOpts = [
{ val: 'createdDate_desc', lbl: 'Newest First' },
{ val: 'createdDate_asc', lbl: 'Oldest First' },
{ val: 'title_asc', lbl: 'Title A-Z' },
{ val: 'eventTime_desc', lbl: 'Event Date' }
];
orderOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.textContent = o.lbl;
if (this.data.orderBy === o.val) opt.selected = true;
orderSelect.appendChild(opt);
});
if (this.readOnly) orderSelect.disabled = true;
orderSelect.addEventListener('change', (e) => this.data.orderBy = e.target.value);
orderDiv.appendChild(orderSelect);
formRow1.appendChild(orderDiv);
this.wrapper.appendChild(formRow1);
// Row 2: Category Filter, Tag Filter, Location Filter & Manual Post IDs
const formRow2 = document.createElement('div');
formRow2.className = 'form-row mb-3 pb-2 border-bottom';
// 1. Category Filter Multi-Select Dropdown
const catDiv = document.createElement('div');
catDiv.className = 'col-md-3 mb-2';
catDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-folder"></i> Category Filter (Multi-Choice = Tabs)</label>';
const catSelect = document.createElement('select');
catSelect.className = 'form-control form-control-sm';
catSelect.multiple = true;
catSelect.style.height = '85px';
const populateCatSelect = () => {
catSelect.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = 'ALL';
allOpt.textContent = '-- All Categories --';
if (!this.data.categoryFilter || this.data.categoryFilter === 'ALL') allOpt.selected = true;
catSelect.appendChild(allOpt);
const list = window.SISSystemCategories || [];
const selectedVals = (this.data.categoryFilter || '').split(',').map(s => s.trim()).filter(Boolean);
list.forEach(c => {
const val = (typeof c === 'string') ? c : (c.name || c.title || '');
if (!val) return;
const opt = document.createElement('option');
opt.value = val;
opt.textContent = val;
if (selectedVals.includes(val)) opt.selected = true;
catSelect.appendChild(opt);
});
};
populateCatSelect();
if (window.SISSystemDataReady) {
window.SISSystemDataReady.then(() => populateCatSelect()).catch(() => {});
}
if (this.readOnly) catSelect.disabled = true;
catSelect.addEventListener('change', () => {
const selected = Array.from(catSelect.selectedOptions).map(o => o.value);
if (selected.includes('ALL') || selected.includes('')) {
this.data.categoryFilter = '';
} else {
this.data.categoryFilter = selected.filter(v => v !== 'ALL').join(',');
}
});
catDiv.appendChild(catSelect);
formRow2.appendChild(catDiv);
// 2. Tag Filter Multi-Select Dropdown
const tagDiv = document.createElement('div');
tagDiv.className = 'col-md-3 mb-2';
tagDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-tags"></i> Tag Filter (Multi-Choice = Tabs)</label>';
const tagSelect = document.createElement('select');
tagSelect.className = 'form-control form-control-sm';
tagSelect.multiple = true;
tagSelect.style.height = '85px';
const populateTagSelect = () => {
tagSelect.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = 'ALL';
allOpt.textContent = '-- All Tags --';
if (!this.data.tagFilter || this.data.tagFilter === 'ALL') allOpt.selected = true;
tagSelect.appendChild(allOpt);
const list = window.SISSystemTags || [];
const selectedVals = (this.data.tagFilter || '').split(',').map(s => s.trim()).filter(Boolean);
list.forEach(t => {
const val = (typeof t === 'string') ? t : (t.name || t.title || '');
if (!val) return;
const opt = document.createElement('option');
opt.value = val;
opt.textContent = val;
if (selectedVals.includes(val)) opt.selected = true;
tagSelect.appendChild(opt);
});
};
populateTagSelect();
if (window.SISSystemDataReady) {
window.SISSystemDataReady.then(() => populateTagSelect()).catch(() => {});
}
if (this.readOnly) tagSelect.disabled = true;
tagSelect.addEventListener('change', () => {
const selected = Array.from(tagSelect.selectedOptions).map(o => o.value);
if (selected.includes('ALL') || selected.includes('')) {
this.data.tagFilter = '';
} else {
this.data.tagFilter = selected.filter(v => v !== 'ALL').join(',');
}
});
tagDiv.appendChild(tagSelect);
formRow2.appendChild(tagDiv);
// 3. Location Filter Attribute
const locDiv = document.createElement('div');
locDiv.className = 'col-md-3 mb-2';
locDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-map-marker-alt"></i> Location Attribute</label>';
const locInput = document.createElement('input');
locInput.type = 'text';
locInput.className = 'form-control form-control-sm';
locInput.placeholder = 'e.g. Cần Thơ, Đà Nẵng';
locInput.value = this.data.locationFilter || '';
if (this.readOnly) locInput.disabled = true;
locInput.addEventListener('input', (e) => this.data.locationFilter = e.target.value.trim());
locDiv.appendChild(locInput);
formRow2.appendChild(locDiv);
// 4. Specific Post IDs
const idsDiv = document.createElement('div');
idsDiv.className = 'col-md-3 mb-2';
idsDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-list-ol"></i> Specific Post IDs</label>';
const idsInput = document.createElement('input');
idsInput.type = 'text';
idsInput.className = 'form-control form-control-sm';
idsInput.placeholder = 'e.g. 101, 102, 105';
idsInput.value = this.data.postIds || '';
if (this.readOnly) idsInput.disabled = true;
idsInput.addEventListener('input', (e) => this.data.postIds = e.target.value.trim());
idsDiv.appendChild(idsInput);
formRow2.appendChild(idsDiv);
this.wrapper.appendChild(formRow2);
// Row 3: HTML ID, CSS Class, Custom Attributes & Dedicated Style
const formRow3 = document.createElement('div');
formRow3.className = 'form-row mb-3 pb-2 border-bottom';
// 1. Container HTML ID
const idDiv = document.createElement('div');
idDiv.className = 'col-md-3 mb-2';
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-hashtag"></i> HTML ID</label>';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.placeholder = 'e.g. posts-section-1';
idInput.value = this.data.globalId || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
this.globalIdInput = idInput;
idDiv.appendChild(idInput);
formRow3.appendChild(idDiv);
// 2. Container CSS Class
const classDiv = document.createElement('div');
classDiv.className = 'col-md-3 mb-2';
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-css3-alt"></i> CSS Class</label>';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.placeholder = 'e.g. sis-posts-block my-custom-grid';
classInput.value = this.data.globalClass || 'sis-posts-block';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
this.globalClassInput = classInput;
classDiv.appendChild(classInput);
formRow3.appendChild(classDiv);
// 3. Dedicated Style Attribute
const styleAttrDiv = document.createElement('div');
styleAttrDiv.className = 'col-md-3 mb-2';
styleAttrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-paint-brush"></i> Dedicated Style</label>';
const styleAttrInput = document.createElement('input');
styleAttrInput.type = 'text';
styleAttrInput.className = 'form-control form-control-sm';
styleAttrInput.placeholder = 'e.g. background: #f8f9fa; padding: 20px;';
styleAttrInput.value = this.data.globalStyle || '';
if (this.readOnly) styleAttrInput.disabled = true;
styleAttrInput.addEventListener('input', (e) => this.data.globalStyle = e.target.value.trim());
this.globalStyleInput = styleAttrInput;
styleAttrDiv.appendChild(styleAttrInput);
formRow3.appendChild(styleAttrDiv);
// 4. Custom Attributes
const attrDiv = document.createElement('div');
attrDiv.className = 'col-md-3 mb-2';
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Custom Attributes</label>';
const attrInput = document.createElement('input');
attrInput.type = 'text';
attrInput.className = 'form-control form-control-sm';
attrInput.placeholder = 'e.g. data-aos="fade-up"';
attrInput.value = this.data.globalAttributes || '';
if (this.readOnly) attrInput.disabled = true;
attrInput.addEventListener('input', (e) => this.data.globalAttributes = e.target.value.trim());
this.globalAttrInput = attrInput;
attrDiv.appendChild(attrInput);
formRow3.appendChild(attrDiv);
this.wrapper.appendChild(formRow3);
// Row 4: Display Toggles (Image, Excerpt, Date, Location)
const toggleRow = document.createElement('div');
toggleRow.className = 'd-flex flex-wrap gap-3 align-items-center p-2 bg-white rounded border mb-2';
toggleRow.style.fontSize = '12px';
const toggles = [
{ id: 'showImg', key: 'showImage', label: 'Featured Image' },
{ id: 'showExc', key: 'showExcerpt', label: 'Excerpt Summary' },
{ id: 'showDt', key: 'showDate', label: 'Published / Event Date' },
{ id: 'showLoc', key: 'showLocation', label: 'Location Tag' },
{ id: 'enableTabs', key: 'enableTabs', label: 'Filter Tabs' },
{ id: 'enablePag', key: 'enablePagination', label: 'Pagination Controls' }
];
toggles.forEach(t => {
const checkWrap = document.createElement('div');
checkWrap.className = 'custom-control custom-checkbox mr-3';
const check = document.createElement('input');
check.type = 'checkbox';
check.className = 'custom-control-input';
check.id = `posts-toggle-${t.id}-${Math.random().toString(36).substr(2, 4)}`;
check.checked = !!this.data[t.key];
if (this.readOnly) check.disabled = true;
check.addEventListener('change', (e) => this.data[t.key] = e.target.checked);
const label = document.createElement('label');
label.className = 'custom-control-label font-weight-bold text-dark';
label.htmlFor = check.id;
label.innerText = t.label;
checkWrap.appendChild(check);
checkWrap.appendChild(label);
toggleRow.appendChild(checkWrap);
});
this.wrapper.appendChild(toggleRow);
return this.wrapper;
}
save() {
return {
category: this.data.category || 'ALL',
categoryFilter: this.data.categoryFilter || '',
tagFilter: this.data.tagFilter || '',
layoutFilter: this.data.layoutFilter || 'ALL',
statusFilter: this.data.statusFilter || 'PUBLISHED',
displayStyle: this.data.displayStyle || 'grid',
cols: parseInt(this.data.cols) || 3,
limit: parseInt(this.data.limit) || 6,
orderBy: this.data.orderBy || 'createdDate_desc',
locationFilter: this.data.locationFilter || '',
postIds: this.data.postIds || '',
showImage: !!this.data.showImage,
showExcerpt: !!this.data.showExcerpt,
showDate: !!this.data.showDate,
showLocation: !!this.data.showLocation,
itemsPerPage: parseInt(this.data.itemsPerPage) || parseInt(this.data.limit) || 9,
enablePagination: !!this.data.enablePagination,
enableTabs: !!this.data.enableTabs,
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || 'sis-posts-block'),
globalStyle: this.globalStyleInput ? this.globalStyleInput.value.trim() : (this.data.globalStyle || ''),
globalAttributes: this.globalAttrInput ? this.globalAttrInput.value.trim() : (this.data.globalAttributes || ''),
customCss: this.data.customCss || '',
customJs: this.data.customJs || ''
};
}
}
// Register the Posts List plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['posts'] = {
class: SISPostsTool
};
@@ -434,6 +434,22 @@
============================================================
-->
<!-- Global system categories and tags for Editor.js plugins — always fetched from API -->
<script>
window.SISSystemCategories = [];
window.SISSystemTags = [];
// Fetch categories and tags from database via REST API.
// window.SISSystemDataReady resolves when both arrays are populated.
window.SISSystemDataReady = Promise.all([
fetch('/api/categories').then(r => r.json()),
fetch('/api/tags').then(r => r.json())
]).then(([cats, tags]) => {
if (Array.isArray(cats)) window.SISSystemCategories = cats;
if (Array.isArray(tags)) window.SISSystemTags = tags;
}).catch(err => console.warn('Failed to load categories/tags from API:', err));
</script>
<!-- Custom SIS Editor Plugins -->
<script th:src="@{/js/manage/editor-plugins/html-snippet.js}"></script>
<script th:src="@{/js/manage/editor-plugins/timeline.js}"></script>
@@ -445,6 +461,7 @@
<script th:src="@{/js/manage/editor-plugins/hero-banner.js}"></script>
<script th:src="@{/js/manage/editor-plugins/text-styling.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tiny-mce.js}"></script>
<script th:src="@{/js/manage/editor-plugins/posts.js}"></script>
<!-- SIS Standalone Media Picker Library -->
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
@@ -443,10 +443,383 @@
</div>
</th:block>
<!-- 18. Custom HTML Tag Block (Opening or Closing Tag) -->
<th:block th:if="${block['type'] == 'tag'}">
<th:block th:if="${block.data['rawHtmlTag'] != null and !#strings.isEmpty(block.data['rawHtmlTag'])}"
th:utext="${block.data['rawHtmlTag']}"></th:block>
<!-- 19. Dynamic Posts List Block -->
<th:block th:if="${block['type'] == 'posts'}">
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : 'sis-posts-block'}"
th:classappend="${block['cssClass']}"
th:styleappend="${(block.data['globalStyle'] != null ? block.data['globalStyle'] + ';' : '') + (block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
th:attr="data-custom-attrs=${block['customAttrs']}"
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : null}">
<div class="container py-4">
<!-- Bento Layout 01 -->
<th:block th:if="${block.data['displayStyle'] == 'bento_layout_01'}">
<style>
.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;
}
.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;
}
.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; }
@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}">
<!-- 1. Left Top -->
<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. Center Featured -->
<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 Title</h2>
</div>
</a>
</div>
<!-- 3. Left Bottom -->
<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>
<!-- 4. Right Top -->
<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. Right Bottom -->
<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>
</th:block>
<!-- Card Grid Layout -->
<th:block th:if="${block.data['displayStyle'] == 'card_grid_layout'}">
<style>
.news-container { max-width: 1200px; margin: 0 auto; width: 100%; }
.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; }
.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 { 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; }
@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 (Configured via posts.js multi-choice) -->
<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';
if (tabsContainer) {
const rawConfigured = tabsContainer.getAttribute('data-configured-tabs') || '';
let tabNames = rawConfigured.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
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);
}
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);
});
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();
});
}
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;
cards.forEach(function (c) { c.style.display = 'none'; });
const startIdx = (currentPage - 1) * itemsPerPage;
const endIdx = startIdx + itemsPerPage;
visibleCards.slice(startIdx, endIdx).forEach(function (c) {
c.style.display = 'flex';
});
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>
</th:block>
<!-- Legacy UMC 5-Card Award Hero Layout -->
<th:block th:if="${block.data['displayStyle'] == 'umc-award-hero' or block.data['displayStyle'] == 'umc-5card-award'}">
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-xl-5 g-3">
<th:block th:if="${posts != null}">
<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']))) and (block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or (p.category != null and #strings.containsIgnoreCase(p.category.name, block.data['categoryFilter']))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (p.tags != null and #lists.contains(p.tags.![name], block.data['tagFilter'])))}">
<div class="col">
<a th:href="@{'/posts/' + ${p.slug}}" class="card h-100 border-0 shadow-sm rounded-lg text-decoration-none overflow-hidden group">
<div class="ratio ratio-4x3 bg-light overflow-hidden">
<img th:src="${p.featuredImage}" th:alt="${p.title}" class="w-100 h-100 object-fit-cover group-hover-scale" />
</div>
<div class="card-body p-3">
<h6 class="card-title text-dark font-weight-bold mb-0" style="font-size: 0.875rem; line-height: 1.4;" th:utext="${p.title}">Title</h6>
</div>
</a>
</div>
</th:block>
</th:block>
</div>
</th:block>
<!-- Standard Grid Layout -->
<th:block th:if="${block.data['displayStyle'] != 'umc-award-hero' and block.data['displayStyle'] != 'umc-5card-award' and block.data['displayStyle'] != 'bento_layout_01' and block.data['displayStyle'] != 'card_grid_layout'}">
<div class="row" th:classappend="${block.data['displayStyle'] == 'grid' or block.data['displayStyle'] == null ? '' : (block.data['displayStyle'] == 'compact' ? 'flex-column' : '')}">
<th:block th:if="${posts != null}">
<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']))) and (block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or (p.category != null and #strings.containsIgnoreCase(p.category.name, block.data['categoryFilter']))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (p.tags != null and #lists.contains(p.tags.![name], block.data['tagFilter'])))}">
<div th:class="${block.data['cols'] == 1 ? 'col-12 mb-4' : (block.data['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (block.data['cols'] == 4 ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg-4 col-md-6 col-12 mb-4'))}">
<div class="card h-100 shadow-sm border-0 rounded-lg overflow-hidden">
<div th:if="${(block.data['showImage'] == null or block.data['showImage'] == true) and p.featuredImage != null and !#strings.isEmpty(p.featuredImage)}" style="height: 200px; overflow: hidden; background: #f8f9fa;">
<img th:src="${p.featuredImage}" th:alt="${p.title}" class="w-100 h-100" style="object-fit: cover;" />
</div>
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div>
<div class="d-flex justify-content-between align-items-center mb-2" style="font-size: 12px;">
<span th:if="${p.category != null}" class="badge badge-pill badge-primary px-2 py-1" th:text="${p.category.name}">Category</span>
<span th:if="${(block.data['showDate'] == null or block.data['showDate'] == true) and p.createdDate != null}" class="text-muted" th:text="${#temporals.format(p.createdDate, 'dd/MM/yyyy')}">01/01/2026</span>
</div>
<h5 class="card-title font-weight-bold text-dark mb-2" style="font-size: 15px; line-height: 1.4;">
<a th:href="@{'/posts/' + ${p.slug}}" class="text-dark text-decoration-none" th:utext="${p.title}">Post Title</a>
</h5>
<p th:if="${(block.data['showExcerpt'] == null or block.data['showExcerpt'] == true) and p.excerpt != null and !#strings.isEmpty(p.excerpt)}" class="card-text text-muted small" th:utext="${p.excerpt}">Excerpt...</p>
</div>
<div th:if="${(block.data['showLocation'] == null or block.data['showLocation'] == true) and p.location != null and !#strings.isEmpty(p.location)}" class="mt-2 text-info small font-weight-bold">
<i class="fas fa-map-marker-alt mr-1"></i> <span th:text="${p.location}">Location</span>
</div>
</div>
</div>
</div>
</th:block>
</th:block>
</div>
</th:block>
</div>
</div>
</th:block>
<!-- Shared Cell Content Renderer Fragment -->