feat: implement Bento layout support and Editor.js integration for dynamic post lists
This commit is contained in:
+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/**", "/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";
|
||||
}
|
||||
|
||||
+49
@@ -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());
|
||||
}
|
||||
}
|
||||
+19
-13
@@ -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">«</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">»</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 -->
|
||||
|
||||
Reference in New Issue
Block a user