feat: add SISAccordionTool for Editor.js and support multi-item accordion blocks

This commit is contained in:
2026-08-12 16:13:36 +07:00
parent c61bf4b15a
commit b6dd223fb3
14 changed files with 4086 additions and 4503 deletions
File diff suppressed because it is too large Load Diff
@@ -56,13 +56,13 @@
.news-section-v3 .featured-title {
font-size: 22px;
font-weight: 700;
color: #0284c7;
color: black;
line-height: 1.3;
cursor: pointer;
text-decoration: none;
}
.news-section-v3 .featured-title:hover {
color: #0369a1;
color: var(--color-old-brick);
}
.news-section-v3 .featured-excerpt {
font-size: 14px;
@@ -130,15 +130,29 @@
<div class="news-section-v3" th:if="${posts != null}">
<th:block th:with="filteredPosts=${posts}">
<article class="featured-post">
<div class="featured-img-box" th:if="${(block.data['showImage'] == null or block.data['showImage'] == true) and firstPost.featuredImage != null and !#strings.isEmpty(firstPost.featuredImage)}">
<div
class="featured-img-box"
th:if="${(block.data['showImage'] == null or block.data['showImage'] == true) and firstPost.featuredImage != null and !#strings.isEmpty(firstPost.featuredImage)}"
>
<img th:src="${firstPost.featuredImage}" th:alt="${firstPost.title}" />
</div>
<div class="meta-row">
<span class="badge-news" th:if="${firstPost.category != null}" th:text="${firstPost.category.name}">Tin tức</span>
<span class="post-date" th:if="${(block.data['showDate'] == null or block.data['showDate'] == true) and firstPost.createdDate != null}" th:text="${#temporals.format(firstPost.createdDate, 'dd/MM/yyyy')}">06/07/2026</span>
<span
class="post-date"
th:if="${(block.data['showDate'] == null or block.data['showDate'] == true) and firstPost.createdDate != null}"
th:text="${#temporals.format(firstPost.createdDate, 'dd/MM/yyyy')}"
>06/07/2026</span
>
</div>
<a th:href="@{'/posts/' + ${firstPost.slug}}" class="featured-title" th:utext="${firstPost.title}">Featured Post Title</a>
<p class="featured-excerpt" th:if="${(block.data['showExcerpt'] == null or block.data['showExcerpt'] == true) and firstPost.excerpt != null and !#strings.isEmpty(firstPost.excerpt)}" th:utext="${firstPost.excerpt}">Featured Excerpt...</p>
<p
class="featured-excerpt"
th:if="${(block.data['showExcerpt'] == null or block.data['showExcerpt'] == true) and firstPost.excerpt != null and !#strings.isEmpty(firstPost.excerpt)}"
th:utext="${firstPost.excerpt}"
>
Featured Excerpt...
</p>
</article>
</th:block>
@@ -146,14 +160,22 @@
<div class="side-news-grid">
<th:block th:each="p, iterStat : ${posts}" th:if="${iterStat.index > 0 and iterStat.index <= 4}">
<a th:href="@{'/posts/' + ${p.slug}}" class="small-post">
<div class="small-img-box" th:if="${(block.data['showImage'] == null or block.data['showImage'] == true) and p.featuredImage != null and !#strings.isEmpty(p.featuredImage)}">
<div
class="small-img-box"
th:if="${(block.data['showImage'] == null or block.data['showImage'] == true) and p.featuredImage != null and !#strings.isEmpty(p.featuredImage)}"
>
<img th:src="${p.featuredImage}" th:alt="${p.title}" />
</div>
<div class="meta-row">
<span class="badge-news" th:if="${p.category != null}" th:text="${p.category.name}">Category</span>
</div>
<h3 class="small-title" th:utext="${p.title}">Small Post Title</h3>
<span class="post-date" th:if="${(block.data['showDate'] == null or block.data['showDate'] == true) and p.createdDate != null}" th:text="${#temporals.format(p.createdDate, 'dd/MM/yyyy')}">04/08/2026</span>
<span
class="post-date"
th:if="${(block.data['showDate'] == null or block.data['showDate'] == true) and p.createdDate != null}"
th:text="${#temporals.format(p.createdDate, 'dd/MM/yyyy')}"
>04/08/2026</span
>
</a>
</th:block>
</div>
@@ -36,11 +36,12 @@ public class MediaController {
@PostMapping("/upload")
public ResponseEntity<Map<String, Object>> uploadFile(@RequestParam("file") MultipartFile file) {
log.debug("REST request to upload file: {}", file.getOriginalFilename());
log.debug("REST request to upload file: {}", file != null ? file.getOriginalFilename() : "null");
Map<String, Object> response = new HashMap<>();
if (file == null || file.isEmpty()) {
log.warn("Upload request file is missing or empty");
response.put("success", 0);
return ResponseEntity.badRequest().body(response);
}
@@ -48,16 +49,18 @@ public class MediaController {
try {
Media media = mediaService.upload(file);
// Construct the Editor.js expected response
// Construct the Editor.js / MediaPicker expected response
Map<String, Object> fileData = new HashMap<>();
fileData.put("url", media.getFileUrl());
fileData.put("name", media.getOriginalFilename());
fileData.put("size", media.getFileSize());
if (media.getMimeType() != null) {
String ext = "";
int dotIndex = media.getOriginalFilename().lastIndexOf('.');
if (dotIndex > 0) {
ext = media.getOriginalFilename().substring(dotIndex + 1);
if (media.getOriginalFilename() != null) {
int dotIndex = media.getOriginalFilename().lastIndexOf('.');
if (dotIndex > 0) {
ext = media.getOriginalFilename().substring(dotIndex + 1);
}
}
fileData.put("extension", ext);
}
@@ -67,9 +70,10 @@ public class MediaController {
return ResponseEntity.ok(response);
} catch (IOException e) {
log.error("Failed to store uploaded file", e);
} catch (Throwable e) {
log.error("Failed to store uploaded file: {}", file.getOriginalFilename(), e);
response.put("success", 0);
response.put("message", e.getMessage() != null ? e.getMessage() : e.getClass().getName());
return ResponseEntity.internalServerError().body(response);
}
}
@@ -2607,3 +2607,42 @@ ul.pagination-container li.disabled .page-link {
font-size: 0.925rem;
}
}
/* SIS Accordion Group Styling & Collapse Behavior */
.sis-accordion-wrapper .card {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.sis-accordion-wrapper .card-header .btn-link {
background-color: var(--color-old-brick, #c0272d) !important;
color: #ffffff !important;
border-radius: 6px;
text-decoration: none !important;
transition: background-color 0.2s ease;
}
.sis-accordion-wrapper .card-header .btn-link:hover {
background-color: #991b1b !important;
color: #ffffff !important;
}
.sis-accordion-wrapper .card-header .btn-link .fa-chevron-down {
transition: transform 0.25s ease;
}
.sis-accordion-wrapper .card-header .btn-link.collapsed .fa-chevron-down {
transform: rotate(-90deg);
}
/* Smooth Accordion Animation using max-height */
.sis-accordion-wrapper .collapse {
max-height: 0;
overflow: hidden;
transition: max-height 0.4s ease-in-out, opacity 0.4s ease-in-out;
opacity: 0;
display: block !important;
}
.sis-accordion-wrapper .collapse.show {
max-height: 2500px; /* Large enough to hold the content */
opacity: 1;
}
@@ -200,129 +200,7 @@ class SISRawHtmlTool {
}
}
class SISAccordionTool {
static get toolbox() {
return {
title: 'Accordion / Collapse',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
title: (data && data.title) ? data.title : '',
content: (data && data.content) ? data.content : '',
isOpen: !!(data && data.isOpen),
fullWidth: !!(data && (data.fullWidth || data.stretched))
};
this.titleInput = null;
this.contentInput = null;
}
render() {
const container = document.createElement('div');
container.style.border = '1px solid #d1d3e2';
container.style.borderRadius = '6px';
container.style.padding = '12px';
container.style.background = '#ffffff';
container.style.marginBottom = '10px';
const headerLabel = document.createElement('label');
headerLabel.className = 'font-weight-bold text-primary small mb-1';
headerLabel.innerHTML = '<i class="fas fa-chevron-circle-down"></i> Accordion Title / Summary';
this.titleInput = document.createElement('input');
this.titleInput.type = 'text';
this.titleInput.className = 'form-control mb-2';
this.titleInput.placeholder = 'Enter accordion header/title...';
this.titleInput.value = this.data.title;
const bodyLabel = document.createElement('label');
bodyLabel.className = 'font-weight-bold text-secondary small mb-1';
bodyLabel.innerHTML = '<i class="fas fa-align-left"></i> Accordion Content / Details';
this.contentInput = document.createElement('textarea');
this.contentInput.className = 'form-control';
this.contentInput.style.minHeight = '100px';
this.contentInput.placeholder = 'Enter body content (HTML or text)...';
this.contentInput.value = this.data.content;
if (this.readOnly) {
this.titleInput.disabled = true;
this.contentInput.disabled = true;
}
this.titleInput.addEventListener('input', () => {
this.data.title = this.titleInput.value;
});
this.contentInput.addEventListener('input', () => {
this.data.content = this.contentInput.value;
});
container.appendChild(headerLabel);
container.appendChild(this.titleInput);
container.appendChild(bodyLabel);
container.appendChild(this.contentInput);
// Open by default toggle
const openWrapper = document.createElement('div');
openWrapper.className = 'custom-control custom-switch mt-2';
this.openCheck = document.createElement('input');
this.openCheck.type = 'checkbox';
this.openCheck.className = 'custom-control-input';
this.openCheck.id = 'acc_open_' + Math.random().toString(36).substring(7);
this.openCheck.checked = !!(this.data && this.data.isOpen);
const openLabel = document.createElement('label');
openLabel.className = 'custom-control-label small font-weight-bold text-primary';
openLabel.htmlFor = this.openCheck.id;
openLabel.innerHTML = '<i class="fas fa-folder-open"></i> Expand / Uncollapse by default after loading page';
this.openCheck.addEventListener('change', () => {
this.data.isOpen = this.openCheck.checked;
});
openWrapper.appendChild(this.openCheck);
openWrapper.appendChild(openLabel);
container.appendChild(openWrapper);
// Stretch toggle
const stretchWrapper = document.createElement('div');
stretchWrapper.className = 'custom-control custom-switch mt-1';
this.fullWidthCheck = document.createElement('input');
this.fullWidthCheck.type = 'checkbox';
this.fullWidthCheck.className = 'custom-control-input';
this.fullWidthCheck.id = 'acc_stretch_' + Math.random().toString(36).substring(7);
this.fullWidthCheck.checked = !!(this.data && this.data.fullWidth);
const stretchLabel = document.createElement('label');
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
stretchLabel.htmlFor = this.fullWidthCheck.id;
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch block to Full Screen Width (Independent Breakout)';
this.fullWidthCheck.addEventListener('change', () => {
this.data.fullWidth = this.fullWidthCheck.checked;
});
stretchWrapper.appendChild(this.fullWidthCheck);
stretchWrapper.appendChild(stretchLabel);
container.appendChild(stretchWrapper);
return container;
}
save() {
return {
title: this.titleInput ? this.titleInput.value : this.data.title,
content: this.contentInput ? this.contentInput.value : this.data.content,
isOpen: this.openCheck ? this.openCheck.checked : !!(this.data && this.data.isOpen),
fullWidth: this.fullWidthCheck ? this.fullWidthCheck.checked : !!(this.data && this.data.fullWidth)
};
}
}
// SISAccordionTool extracted to /js/manage/editor-plugins/accordion.js
// SISHeroBannerTool extracted to /js/manage/editor-plugins/hero-banner.js
@@ -425,7 +303,9 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
// Custom SIS Tools (Always present)
builtInTools.raw = { class: SISRawHtmlTool };
builtInTools.accordion = { class: SISAccordionTool };
if (typeof SISAccordionTool !== 'undefined') {
builtInTools.accordion = { class: SISAccordionTool };
}
builtInTools.youtube = { class: SISYouTubeTool };
if (typeof SISHeroBannerTool !== 'undefined') {
builtInTools.hero = { class: SISHeroBannerTool };
@@ -0,0 +1,578 @@
/**
* SISAccordionTool — Accordion / Collapse Block Tool for Editor.js
* Supports both Single Accordion (<details>) and Bootstrap Multi-Item Accordion Group (temp_2.html),
* with full plugin injection (Posts, Youtube, Cards, etc.) into any accordion item body.
*/
class SISAccordionTool {
static get toolbox() {
return {
title: 'Accordion / Collapse',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.activeSubPlugins = {};
this.data = {
layoutStyle: (data && data.layoutStyle) ? data.layoutStyle : ((data && data.items && data.items.length > 0) ? 'bootstrap_group' : 'single'),
title: (data && data.title) ? data.title : '',
type: (data && (data.type || data.contentType)) ? (data.type || data.contentType) : 'html',
contentType: (data && (data.contentType || data.type)) ? (data.contentType || data.type) : 'html',
content: (data && data.content !== undefined) ? data.content : '',
isOpen: !!(data && data.isOpen),
fullWidth: !!(data && (data.fullWidth || data.stretched)),
items: (data && data.items && Array.isArray(data.items)) ? JSON.parse(JSON.stringify(data.items)) : [],
globalId: (data && data.globalId) || '',
globalClass: (data && data.globalClass) || '',
globalStyle: (data && data.globalStyle) || '',
globalAttributes: (data && data.globalAttributes) || (data && data.attributes) || ''
};
// Initialize default multi-items if bootstrap_group is selected but items is empty
if (this.data.items.length === 0) {
this.data.items.push(
{ title: 'Collapsible Group Item #1', type: 'html', content: 'Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid.', isOpen: true },
{ title: 'Collapsible Group Item #2', type: 'html', content: 'Food truck quinoa nesciunt laborum eiusmod.', isOpen: false },
{ title: 'Collapsible Group Item #3', type: 'html', content: 'Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident.', isOpen: false }
);
}
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-accordion-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
this.wrapper.style.borderLeft = '4px solid #36b9cc';
// Header Title
const headerDiv = document.createElement('div');
headerDiv.className = 'd-flex justify-content-between align-items-center mb-3 pb-2 border-bottom';
headerDiv.innerHTML = '<div class="font-weight-bold text-info small"><i class="fas fa-chevron-circle-down mr-1"></i> Accordion / Collapse Settings</div><span class="badge badge-info px-2 py-1">ACCORDION PLUGIN</span>';
this.wrapper.appendChild(headerDiv);
// Template / Layout Style Selector
const layoutRow = document.createElement('div');
layoutRow.className = 'form-row mb-3 pb-2 border-bottom';
const styleDiv = document.createElement('div');
styleDiv.className = 'col-md-6 mb-2';
styleDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-layer-group"></i> Accordion Template / Style</label>';
const layoutSelect = document.createElement('select');
layoutSelect.className = 'custom-select custom-select-sm';
if (this.readOnly) layoutSelect.disabled = true;
const layoutOpts = [
{ val: 'single', lbl: 'Single Accordion (<details> tag)' },
{ val: 'bootstrap_group', lbl: 'Bootstrap Accordion Group (Multi-Card Group)' }
];
layoutOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.textContent = o.lbl;
if (this.data.layoutStyle === o.val) opt.selected = true;
layoutSelect.appendChild(opt);
});
styleDiv.appendChild(layoutSelect);
layoutRow.appendChild(styleDiv);
// Full width breakout toggle
const stretchDiv = document.createElement('div');
stretchDiv.className = 'col-md-6 mb-2 d-flex align-items-center pt-3';
const stretchWrapper = document.createElement('div');
stretchWrapper.className = 'custom-control custom-switch';
this.fullWidthCheck = document.createElement('input');
this.fullWidthCheck.type = 'checkbox';
this.fullWidthCheck.className = 'custom-control-input';
this.fullWidthCheck.id = 'acc_stretch_' + Math.random().toString(36).substring(7);
this.fullWidthCheck.checked = !!(this.data && this.data.fullWidth);
const stretchLabel = document.createElement('label');
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
stretchLabel.htmlFor = this.fullWidthCheck.id;
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch to Full Screen Width';
this.fullWidthCheck.addEventListener('change', () => {
this.data.fullWidth = this.fullWidthCheck.checked;
});
stretchWrapper.appendChild(this.fullWidthCheck);
stretchWrapper.appendChild(stretchLabel);
stretchDiv.appendChild(stretchWrapper);
layoutRow.appendChild(stretchDiv);
this.wrapper.appendChild(layoutRow);
// Dynamic Main Container
this.mainContainer = document.createElement('div');
this.wrapper.appendChild(this.mainContainer);
layoutSelect.addEventListener('change', (e) => {
this.data.layoutStyle = e.target.value;
this._renderMainView();
});
this._renderMainView();
return this.wrapper;
}
_renderMainView() {
if (!this.mainContainer) return;
this.mainContainer.innerHTML = '';
this._destroySubPlugins();
if (this.data.layoutStyle === 'bootstrap_group') {
this._renderBootstrapGroupView();
} else {
this._renderSingleView();
}
}
_renderSingleView() {
const container = document.createElement('div');
// Title Input
const headerLabel = document.createElement('label');
headerLabel.className = 'font-weight-bold text-primary small mb-1';
headerLabel.innerHTML = '<i class="fas fa-heading"></i> Accordion Title / Summary';
this.titleInput = document.createElement('input');
this.titleInput.type = 'text';
this.titleInput.className = 'form-control mb-2';
this.titleInput.placeholder = 'Enter accordion header/title...';
this.titleInput.value = this.data.title || '';
if (this.readOnly) this.titleInput.disabled = true;
this.titleInput.addEventListener('input', () => {
this.data.title = this.titleInput.value;
});
container.appendChild(headerLabel);
container.appendChild(this.titleInput);
// Content Type / Plugin Selector
const typeLabel = document.createElement('label');
typeLabel.className = 'font-weight-bold text-secondary small mb-1 mt-2';
typeLabel.innerHTML = '<i class="fas fa-plug"></i> Content Type / Plugin Injection';
const typeSelect = document.createElement('select');
typeSelect.className = 'custom-select custom-select-sm mb-2';
if (this.readOnly) typeSelect.disabled = true;
const types = this._getAvailableTypes();
types.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.textContent = t.label;
if ((this.data.type || 'html') === t.value) opt.selected = true;
typeSelect.appendChild(opt);
});
container.appendChild(typeLabel);
container.appendChild(typeSelect);
// Content Body Container
const contentContainer = document.createElement('div');
contentContainer.className = 'accordion-content-input-container p-2 border rounded bg-white mb-2';
container.appendChild(contentContainer);
typeSelect.addEventListener('change', (e) => {
this.data.type = e.target.value;
this.data.contentType = e.target.value;
this._renderSubInput('single', this.data, contentContainer);
});
this._renderSubInput('single', this.data, contentContainer);
// Open by default toggle
const openWrapper = document.createElement('div');
openWrapper.className = 'custom-control custom-switch mt-2';
this.openCheck = document.createElement('input');
this.openCheck.type = 'checkbox';
this.openCheck.className = 'custom-control-input';
this.openCheck.id = 'acc_open_' + Math.random().toString(36).substring(7);
this.openCheck.checked = !!(this.data && this.data.isOpen);
const openLabel = document.createElement('label');
openLabel.className = 'custom-control-label small font-weight-bold text-primary';
openLabel.htmlFor = this.openCheck.id;
openLabel.innerHTML = '<i class="fas fa-folder-open"></i> Expand / Uncollapse by default on page load';
this.openCheck.addEventListener('change', () => {
this.data.isOpen = this.openCheck.checked;
});
openWrapper.appendChild(this.openCheck);
openWrapper.appendChild(openLabel);
container.appendChild(openWrapper);
this.mainContainer.appendChild(container);
}
_renderBootstrapGroupView() {
const container = document.createElement('div');
// Global Attributes Row
const globalRow = document.createElement('div');
globalRow.className = 'form-row mb-3 pb-2 border-bottom';
// 1. 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">CSS Class (Wrapper)</label>';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.placeholder = 'e.g. my-accordion-group';
classInput.value = this.data.globalClass || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
classDiv.appendChild(classInput);
globalRow.appendChild(classDiv);
// 2. 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">HTML ID</label>';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.placeholder = 'e.g. accordion-1';
idInput.value = this.data.globalId || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
idDiv.appendChild(idInput);
globalRow.appendChild(idDiv);
// 3. Inline 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">Inline Style</label>';
const styleInput = document.createElement('input');
styleInput.type = 'text';
styleInput.className = 'form-control form-control-sm';
styleInput.placeholder = 'e.g. margin-bottom: 20px;';
styleInput.value = this.data.globalStyle || '';
if (this.readOnly) styleInput.disabled = true;
styleInput.addEventListener('input', (e) => this.data.globalStyle = e.target.value.trim());
styleDiv.appendChild(styleInput);
globalRow.appendChild(styleDiv);
// 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">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());
attrDiv.appendChild(attrInput);
globalRow.appendChild(attrDiv);
container.appendChild(globalRow);
// Items Container
this.itemsContainer = document.createElement('div');
container.appendChild(this.itemsContainer);
this._renderItemsList();
// Add Item Button
if (!this.readOnly) {
const addActionDiv = document.createElement('div');
addActionDiv.className = 'text-center mt-3 pt-2 border-top';
const addItemBtn = document.createElement('button');
addItemBtn.type = 'button';
addItemBtn.className = 'btn btn-sm btn-outline-info shadow-sm px-4';
addItemBtn.innerHTML = '<i class="fas fa-plus mr-1"></i> + Thêm Accordion Item Mới';
addItemBtn.addEventListener('click', () => this._addItem());
addActionDiv.appendChild(addItemBtn);
container.appendChild(addActionDiv);
}
this.mainContainer.appendChild(container);
}
_addItem() {
const nextNum = this.data.items.length + 1;
this.data.items.push({
title: `Collapsible Group Item #${nextNum}`,
type: 'html',
content: '',
isOpen: nextNum === 1
});
this._renderItemsList();
}
_deleteItem(index) {
if (this.data.items.length <= 1) return;
this.data.items.splice(index, 1);
this._renderItemsList();
}
_moveItem(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= this.data.items.length) return;
const temp = this.data.items[index];
this.data.items[index] = this.data.items[newIndex];
this.data.items[newIndex] = temp;
this._renderItemsList();
}
_renderItemsList() {
if (!this.itemsContainer) return;
this.itemsContainer.innerHTML = '';
this._destroySubPlugins();
this.data.items.forEach((item, index) => {
const card = document.createElement('div');
card.className = 'mb-3 p-3 border rounded bg-white ce-acc-item-card shadow-sm position-relative';
// Header Row
const headerRow = document.createElement('div');
headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-2 border-bottom';
const leftControls = document.createElement('div');
leftControls.className = 'd-flex align-items-center gap-2';
const badge = document.createElement('span');
badge.className = 'badge badge-info mr-2';
badge.innerText = `Item #${index + 1}`;
leftControls.appendChild(badge);
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.className = 'form-control form-control-sm font-weight-bold';
titleInput.style.width = '240px';
titleInput.placeholder = 'Tên tiêu đề Accordion...';
titleInput.value = item.title || '';
if (this.readOnly) titleInput.disabled = true;
titleInput.addEventListener('input', (e) => item.title = e.target.value);
leftControls.appendChild(titleInput);
headerRow.appendChild(leftControls);
const rightControls = document.createElement('div');
rightControls.className = 'd-flex align-items-center gap-1';
const moveUpBtn = document.createElement('button');
moveUpBtn.type = 'button';
moveUpBtn.className = 'btn btn-xs btn-outline-secondary mr-1';
moveUpBtn.innerText = '▲';
if (index === 0 || this.readOnly) moveUpBtn.disabled = true;
moveUpBtn.addEventListener('click', () => this._moveItem(index, -1));
const moveDownBtn = document.createElement('button');
moveDownBtn.type = 'button';
moveDownBtn.className = 'btn btn-xs btn-outline-secondary mr-2';
moveDownBtn.innerText = '▼';
if (index === this.data.items.length - 1 || this.readOnly) moveDownBtn.disabled = true;
moveDownBtn.addEventListener('click', () => this._moveItem(index, 1));
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'btn btn-xs btn-danger';
deleteBtn.innerText = '✕';
if (this.data.items.length <= 1 || this.readOnly) deleteBtn.disabled = true;
deleteBtn.addEventListener('click', () => this._deleteItem(index));
rightControls.appendChild(moveUpBtn);
rightControls.appendChild(moveDownBtn);
rightControls.appendChild(deleteBtn);
headerRow.appendChild(rightControls);
card.appendChild(headerRow);
// Plugin / Type Select
const typeRow = document.createElement('div');
typeRow.className = 'form-group mb-2';
const typeLabel = document.createElement('label');
typeLabel.className = 'small font-weight-bold text-secondary mb-1';
typeLabel.innerText = 'Loại nội dung / Plugin Injection:';
typeRow.appendChild(typeLabel);
const typeSelect = document.createElement('select');
typeSelect.className = 'custom-select custom-select-sm';
if (this.readOnly) typeSelect.disabled = true;
const types = this._getAvailableTypes();
types.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.textContent = t.label;
if ((item.type || 'html') === t.value) opt.selected = true;
typeSelect.appendChild(opt);
});
typeRow.appendChild(typeSelect);
card.appendChild(typeRow);
// Sub Input Container
const contentContainer = document.createElement('div');
contentContainer.className = 'item-content-container p-2 border rounded bg-light mb-2';
card.appendChild(contentContainer);
typeSelect.addEventListener('change', (e) => {
item.type = e.target.value;
item.contentType = e.target.value;
this._renderSubInput(index, item, contentContainer);
});
this._renderSubInput(index, item, contentContainer);
// Open by default toggle for item
const openWrap = document.createElement('div');
openWrap.className = 'custom-control custom-switch mt-1';
const openCheck = document.createElement('input');
openCheck.type = 'checkbox';
openCheck.className = 'custom-control-input';
openCheck.id = `item_open_${index}_${Math.random().toString(36).substring(7)}`;
openCheck.checked = !!item.isOpen;
if (this.readOnly) openCheck.disabled = true;
const openLbl = document.createElement('label');
openLbl.className = 'custom-control-label small font-weight-bold text-secondary';
openLbl.htmlFor = openCheck.id;
openLbl.innerText = 'Expand / Mở mặc định khi tải trang';
openCheck.addEventListener('change', (e) => item.isOpen = e.target.checked);
openWrap.appendChild(openCheck);
openWrap.appendChild(openLbl);
card.appendChild(openWrap);
this.itemsContainer.appendChild(card);
});
}
_getAvailableTypes() {
const types = [
{ value: 'html', label: 'HTML / Plain Text' },
{ value: 'tinymce', label: 'TinyMCE Rich Text Editor' }
];
if (window.SISEditorPlugins) {
const seenPlugins = new Set();
Object.keys(window.SISEditorPlugins).forEach(key => {
if (key === 'accordion') return;
const entry = window.SISEditorPlugins[key];
const pluginObj = entry ? (entry.class || entry) : null;
if (pluginObj && seenPlugins.has(pluginObj)) return;
if (pluginObj) seenPlugins.add(pluginObj);
types.push({ value: key, label: `[Plugin] ${key}` });
});
}
return types;
}
_renderSubInput(key, targetDataObj, container) {
container.innerHTML = '';
if (this.activeSubPlugins[key] && typeof this.activeSubPlugins[key].destroy === 'function') {
try { this.activeSubPlugins[key].destroy(); } catch (e) {}
}
delete this.activeSubPlugins[key];
const type = targetDataObj.type || targetDataObj.contentType || 'html';
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
try {
const PluginClass = window.SISEditorPlugins[type].class;
const pluginInstance = new PluginClass({
data: (typeof targetDataObj.content === 'object' && targetDataObj.content !== null) ? targetDataObj.content : {},
api: this.api,
readOnly: this.readOnly
});
this.activeSubPlugins[key] = pluginInstance;
const pluginNode = pluginInstance.render();
container.appendChild(pluginNode);
return;
} catch (err) {
console.warn(`[SISAccordionTool] Error rendering sub-plugin '${type}':`, err);
}
}
// Standard HTML Textarea
const textarea = document.createElement('textarea');
textarea.className = 'form-control form-control-sm';
textarea.rows = 4;
textarea.placeholder = 'Enter body content (HTML or text)...';
textarea.value = (typeof targetDataObj.content === 'string') ? targetDataObj.content : JSON.stringify(targetDataObj.content || '');
if (this.readOnly) textarea.disabled = true;
textarea.addEventListener('input', (e) => {
targetDataObj.content = e.target.value;
});
container.appendChild(textarea);
}
_destroySubPlugins() {
Object.keys(this.activeSubPlugins).forEach(k => {
if (this.activeSubPlugins[k] && typeof this.activeSubPlugins[k].destroy === 'function') {
try { this.activeSubPlugins[k].destroy(); } catch (e) {}
}
});
this.activeSubPlugins = {};
}
save() {
if (this.data.layoutStyle === 'bootstrap_group') {
this.data.items.forEach((item, idx) => {
if (this.activeSubPlugins[idx] && typeof this.activeSubPlugins[idx].save === 'function') {
try {
item.content = this.activeSubPlugins[idx].save();
} catch (err) {
console.warn(`[SISAccordionTool] Save error for item ${idx}:`, err);
}
}
});
return {
layoutStyle: 'bootstrap_group',
items: this.data.items,
fullWidth: this.fullWidthCheck ? this.fullWidthCheck.checked : !!(this.data && this.data.fullWidth),
globalId: this.data.globalId || '',
globalClass: this.data.globalClass || '',
globalStyle: this.data.globalStyle || '',
globalAttributes: this.data.globalAttributes || ''
};
} else {
let singleContent = this.data.content;
if (this.activeSubPlugins['single'] && typeof this.activeSubPlugins['single'].save === 'function') {
try {
singleContent = this.activeSubPlugins['single'].save();
} catch (err) {
console.warn('[SISAccordionTool] Save single sub-plugin error:', err);
}
}
return {
layoutStyle: 'single',
title: this.titleInput ? this.titleInput.value : (this.data.title || ''),
type: this.data.type || 'html',
contentType: this.data.type || 'html',
content: singleContent,
isOpen: this.openCheck ? this.openCheck.checked : !!(this.data && this.data.isOpen),
fullWidth: this.fullWidthCheck ? this.fullWidthCheck.checked : !!(this.data && this.data.fullWidth)
};
}
}
destroy() {
this._destroySubPlugins();
}
}
// Register the Accordion plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['accordion'] = {
class: SISAccordionTool
};
@@ -118,25 +118,40 @@ class SISPostsTool {
// 3. Limit (Number of posts)
const limitDiv = document.createElement('div');
limitDiv.className = 'col-md-2 mb-2';
limitDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Limit Posts</label>';
limitDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Total Limit</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;
limitInput.max = 1000000;
if (this.readOnly) limitInput.disabled = true;
limitInput.addEventListener('input', (e) => {
const val = parseInt(e.target.value) || 12;
this.data.limit = val;
this.data.itemsPerPage = val;
this.data.limit = parseInt(e.target.value);
});
limitDiv.appendChild(limitInput);
formRow1.appendChild(limitDiv);
// 3.5 Items Per Page (Pagination)
const itemsPerPageDiv = document.createElement('div');
itemsPerPageDiv.className = 'col-md-2 mb-2';
itemsPerPageDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Items / Page</label>';
const itemsPerPageInput = document.createElement('input');
itemsPerPageInput.type = 'number';
itemsPerPageInput.className = 'form-control form-control-sm';
itemsPerPageInput.value = this.data.itemsPerPage;
itemsPerPageInput.min = 1;
itemsPerPageInput.max = 1000;
if (this.readOnly) itemsPerPageInput.disabled = true;
itemsPerPageInput.addEventListener('input', (e) => {
this.data.itemsPerPage = parseInt(e.target.value) || 12;
});
itemsPerPageDiv.appendChild(itemsPerPageInput);
formRow1.appendChild(itemsPerPageDiv);
// 4. Order By
const orderDiv = document.createElement('div');
orderDiv.className = 'col-md-2 mb-2';
orderDiv.className = 'col-md-3 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';
@@ -605,7 +605,8 @@
self.showConfigPanel(data.file.url, data.file);
}, 500);
} else {
alert('Tải tệp lên thất bại. Vui lòng thử lại!');
var msg = (data && data.message) ? data.message : 'Vui lòng thử lại!';
alert('Tải tệp lên thất bại: ' + msg);
self._resetAndFetch();
}
})
@@ -71,7 +71,14 @@
</div>
<div class="form-group">
<label for="imageUrl">Image URL (Tùy chọn - Dùng cho Mega Menu Cột 4)</label>
<input type="text" class="form-control" id="imageUrl" name="imageUrl" placeholder="VD: /images/banner.jpg">
<div class="input-group">
<input type="text" class="form-control" id="imageUrl" name="imageUrl" placeholder="VD: /images/banner.jpg">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="btnSelectImageUrl">
<i class="fas fa-image mr-1"></i>Chọn Ảnh
</button>
</div>
</div>
</div>
<div class="form-group">
<label for="parentId">Parent Item (Optional)</label>
@@ -249,7 +256,14 @@
</div>
<div class="form-group">
<label>Image URL (Tùy chọn - Dùng cho Mega Menu Cột 4)</label>
<input type="text" class="form-control" id="modalImageUrl" name="imageUrl">
<div class="input-group">
<input type="text" class="form-control" id="modalImageUrl" name="imageUrl">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="btnSelectModalImageUrl">
<i class="fas fa-image mr-1"></i>Chọn Ảnh
</button>
</div>
</div>
</div>
<div class="form-group">
<label>Parent Item (Optional)</label>
@@ -309,6 +323,7 @@
</div>
<th:block layout:fragment="scripts">
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
<script th:inline="javascript">
$(document).ready(function () {
var menuId = /*[[${menu.id}]]*/ '';
@@ -352,6 +367,23 @@
$('#deleteItemForm').attr('action', actionUrl);
$('#deleteItemModal').modal('show');
});
// SIS Media Picker Handlers
$('#btnSelectImageUrl').on('click', function () {
if (typeof SISMediaPicker !== 'undefined') {
SISMediaPicker.open(function (url) {
$('#imageUrl').val(url);
}, $('#imageUrl').val());
}
});
$('#btnSelectModalImageUrl').on('click', function () {
if (typeof SISMediaPicker !== 'undefined') {
SISMediaPicker.open(function (url) {
$('#modalImageUrl').val(url);
}, $('#modalImageUrl').val());
}
});
});
</script>
</th:block>
@@ -473,6 +473,7 @@
<script th:src="@{/js/manage/editor-plugins/cards.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tab-livestream.js}"></script>
<script th:src="@{/js/manage/editor-plugins/human-resource.js}"></script>
<script th:src="@{/js/manage/editor-plugins/accordion.js}"></script>
<!-- SIS Standalone Media Picker Library -->
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
@@ -37,7 +37,7 @@
<div layout:fragment="content">
<!-- Dedicated Page Custom CSS -->
<style th:if="${page != null and page.customCss != null and !page.customCss.empty}" th:utext="${page.customCss}"></style>
<th:block th:each="block : ${blocks}">
<th:block th:each="block, blockStat : ${blocks}">
<div
th:class="${(block.data != null and block.data['stretched'] == true) or (block.data != null and block.data['stretched'] == null and block['stretched'] == true) ? 'sis-fullwidth-breakout' : ''}"
>
@@ -156,14 +156,57 @@
<!-- 10. Accordion Block -->
<th:block th:if="${block['type'] == 'accordion'}">
<details class="sis-accordion-item mb-3" th:open="${block.data['isOpen'] == true}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
<summary class="sis-accordion-title font-weight-bold p-3 bg-light border rounded" style="cursor: pointer; user-select: none">
<span th:utext="${block.data['title']}">Accordion Title</span>
</summary>
<div class="sis-accordion-body p-3 border border-top-0 rounded-bottom">
<div th:utext="${block.data['content']}">Accordion Content</div>
</div>
</details>
<!-- Mode 1: Multi-Item Bootstrap Accordion Group (from temp_2.html) -->
<th:block th:if="${block.data['layoutStyle'] == 'bootstrap_group' or (block.data['items'] != null and !#lists.isEmpty(block.data['items']))}">
<th:block th:with="accId=${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : ('acc-group-' + blockStat.index + '-' + (block['elementId'] != null ? block['elementId'] : 'id'))}">
<div th:id="${accId}"
class="accordion sis-accordion-wrapper mb-3"
th:classappend="${(block.data['globalClass'] != null ? block.data['globalClass'] + ' ' : '') + (block['cssClass'] != null ? block['cssClass'] : '') + ((block.data != null and (block.data['stretched'] == true or block.data['fullWidth'] == true)) or block['stretched'] == true or block['fullWidth'] == true ? ' sis-fullwidth-breakout px-0 w-100' : '')}"
th:styleappend="${(block.data['globalStyle'] != null ? block.data['globalStyle'] + ';' : '') + (block['customStyle'] != null ? block['customStyle'] : '')}"
th:attr="data-custom-attrs=${block['customAttrs']}"
th:data-custom-attrs-original="${block.data['globalAttributes']}">
<div class="card mb-2" th:each="item, iterStat : ${block.data['items']}">
<div class="card-header bg-light p-0" th:id="${accId + '-head-' + iterStat.index}">
<h5 class="mb-0">
<button class="btn btn-link w-100 text-left font-weight-bold p-3 text-dark d-flex justify-content-between align-items-center sis-accordion-toggle-btn"
type="button"
th:data-target="${'#' + accId + '-collapse-' + iterStat.index}"
th:data-bs-target="${'#' + accId + '-collapse-' + iterStat.index}"
th:aria-expanded="${item['isOpen'] == true or (iterStat.first and item['isOpen'] == null) ? 'true' : 'false'}"
th:aria-controls="${accId + '-collapse-' + iterStat.index}"
th:classappend="${item['isOpen'] == true or (iterStat.first and item['isOpen'] == null) ? '' : 'collapsed'}">
<span th:utext="${item['title'] != null and !#strings.isEmpty(item['title']) ? item['title'] : ('Item #' + iterStat.count)}">Collapsible Group Item</span>
<i class="fas fa-chevron-down small ml-2 text-secondary"></i>
</button>
</h5>
</div>
<div th:id="${accId + '-collapse-' + iterStat.index}"
class="collapse"
th:classappend="${item['isOpen'] == true or (iterStat.first and item['isOpen'] == null) ? 'show' : ''}"
th:aria-labelledby="${accId + '-head-' + iterStat.index}"
th:data-parent="${'#' + accId}"
th:data-bs-parent="${'#' + accId}">
<div class="card-body p-3 border-top">
<div th:replace=":: renderCell(type=${item['type'] != null ? item['type'] : (item['contentType'] != null ? item['contentType'] : 'html')}, i=${iterStat.index + 1}, blockData=${item})"></div>
</div>
</div>
</div>
</div>
</th:block>
</th:block>
<!-- Mode 2: Single Accordion (<details>) -->
<th:block th:unless="${block.data['layoutStyle'] == 'bootstrap_group' or (block.data['items'] != null and !#lists.isEmpty(block.data['items']))}">
<details class="sis-accordion-item mb-3" th:open="${block.data['isOpen'] == true}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
<summary class="sis-accordion-title font-weight-bold p-3 bg-light border rounded" style="cursor: pointer; user-select: none">
<span th:utext="${block.data['title']}">Accordion Title</span>
</summary>
<div class="sis-accordion-body p-3 border border-top-0 rounded-bottom">
<div th:replace=":: renderCell(type=${block.data['type'] != null and block.data['type'] != 'accordion' ? block.data['type'] : (block.data['contentType'] != null and block.data['contentType'] != 'accordion' ? block.data['contentType'] : 'html')}, i=1, blockData=${block.data})"></div>
</div>
</details>
</th:block>
</th:block>
<!-- 11. YouTube Video Block -->
@@ -749,7 +792,7 @@
<th:block th:if="${block.data['showViewMore'] == true and block.data['viewMoreUrl'] != null and !#strings.isEmpty(block.data['viewMoreUrl'])}">
<style>
.posts-view-more-wrap { display: flex; justify-content: flex-end; margin-bottom: 20px; }
.posts-view-more-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 20px; background-color: #0284c7; color: #ffffff; font-size: 14px; font-weight: 600; border-radius: 6px; text-decoration: none; transition: background-color 0.2s ease, transform 0.15s ease; }
.posts-view-more-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 20px; background-color: var(--color-old-brick); color: #ffffff; font-size: 14px; font-weight: 600; border-radius: 6px; text-decoration: none; transition: background-color 0.2s ease, transform 0.15s ease; }
.posts-view-more-link:hover { background-color: #0369a1; color: #ffffff; transform: translateY(-1px); text-decoration: none; }
</style>
<div class="posts-view-more-wrap">
@@ -869,7 +912,7 @@
">
<!-- 1. Left Top -->
<div class="bento-item left-top" th:if="${#lists.size(filteredPosts) > 0}" th:with="p=${filteredPosts[0]}" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
<a th:href="@{'/posts/' + ${p.slug}}" class="text-decoration-none text-white d-block h-100 w-100">
<a th:href="@{'/post/' + ${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>
@@ -878,7 +921,7 @@
</div>
<!-- 2. Center Featured -->
<div class="bento-item center-featured" th:if="${#lists.size(filteredPosts) > 2}" th:with="p=${filteredPosts[2]}" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
<a th:href="@{'/posts/' + ${p.slug}}" class="text-decoration-none text-white d-block h-100 w-100">
<a th:href="@{'/post/' + ${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>
@@ -887,7 +930,7 @@
</div>
<!-- 3. Left Bottom -->
<div class="bento-item left-bottom" th:if="${#lists.size(filteredPosts) > 1}" th:with="p=${filteredPosts[1]}" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
<a th:href="@{'/posts/' + ${p.slug}}" class="text-decoration-none text-white d-block h-100 w-100">
<a th:href="@{'/post/' + ${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>
@@ -897,7 +940,7 @@
<!-- 4. Right Top -->
<div class="bento-item right-top" th:if="${#lists.size(filteredPosts) > 3}" th:with="p=${filteredPosts[3]}" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
<a th:href="@{'/posts/' + ${p.slug}}" class="text-decoration-none text-white d-block h-100 w-100">
<a th:href="@{'/post/' + ${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>
@@ -906,7 +949,7 @@
</div>
<!-- 5. Right Bottom -->
<div class="bento-item right-bottom" th:if="${#lists.size(filteredPosts) > 4}" th:with="p=${filteredPosts[4]}" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
<a th:href="@{'/posts/' + ${p.slug}}" class="text-decoration-none text-white d-block h-100 w-100">
<a th:href="@{'/post/' + ${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>
@@ -939,7 +982,7 @@
</style>
<div class="training-container" th:if="${posts != null}">
<div class="training-grid news-grid" th:data-items-per-page="${block.data['limit'] != null and block.data['limit'] > 0 ? block.data['limit'] : 12}">
<div class="training-grid news-grid" th:data-items-per-page="${block.data['itemsPerPage'] != null and block.data['itemsPerPage'] > 0 ? block.data['itemsPerPage'] : (block.data['limit'] != null and block.data['limit'] > 0 ? block.data['limit'] : 12)}">
<th:block th:each="p : ${posts}"
th:if="${(
(block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or #strings.contains(block.data['categoryFilter'], 'ALL'))
@@ -954,7 +997,7 @@
th:data-category="${p.category != null ? p.category.name : ''}"
th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
<div>
<a th:href="@{'/posts/' + ${p.slug}}" class="d-block text-decoration-none">
<a th:href="@{'/post/' + ${p.slug}}" class="d-block text-decoration-none">
<div class="training-thumb">
<img th:src="${p.featuredImage}" th:alt="${p.title}" />
</div>
@@ -970,7 +1013,7 @@
</div>
<span class="training-badge">SAT</span>
</div>
<a th:href="@{'/posts/' + ${p.slug}}" class="training-price" th:text="${p.price != null and !#strings.isEmpty(p.price) ? p.price : 'LIÊN HỆ'}">14.999.998 VNĐ</a>
<a th:href="@{'/post/' + ${p.slug}}" class="training-price" th:text="${p.price != null and !#strings.isEmpty(p.price) ? p.price : 'LIÊN HỆ'}">14.999.998 VNĐ</a>
</div>
</div>
</th:block>
@@ -993,7 +1036,7 @@
if (!grid) return;
const cards = Array.from(grid.querySelectorAll('.news-card'));
const itemsPerPage = Math.min(parseInt(grid.getAttribute('data-items-per-page')) || 12, 12);
const itemsPerPage = parseInt(grid.getAttribute('data-items-per-page')) || 12;
let currentPage = 1;
function render() {
@@ -1070,7 +1113,7 @@
.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; }
.tab-btn.active { background-color: var(--color-old-brick); 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); }
@@ -1079,11 +1122,12 @@
.news-card:hover .card-thumb img { transform: scale(1.05); }
.year-badge { position: absolute; top: 10px; right: 10px; background-color: var(--color-old-brick); 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: var(--color-old-brick); line-height: 1.45; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.card-title { font-size: 14px; font-weight: 700; color: black; line-height: 1.45; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.card-title:hover { color: var(--color-old-brick);}
.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-link:hover { border-color: var(--color-old-brick); color: var(--color-old-brick); }
.page-link.active { background-color: var(--color-old-brick); color: #ffffff; border-color: var(--color-old-brick); }
.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; } }
@@ -1092,7 +1136,7 @@
<div class="news-container" th:if="${posts != null}">
<!-- 2. CARD GRID -->
<div class="news-grid" th:data-items-per-page="${block.data['limit'] != null and block.data['limit'] > 0 ? block.data['limit'] : 12}">
<div class="news-grid" th:data-items-per-page="${block.data['itemsPerPage'] != null and block.data['itemsPerPage'] > 0 ? block.data['itemsPerPage'] : (block.data['limit'] != null and block.data['limit'] > 0 ? block.data['limit'] : 12)}">
<th:block th:each="p : ${posts}"
th:if="${(
(block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or #strings.contains(block.data['categoryFilter'], 'ALL'))
@@ -1103,7 +1147,7 @@
or
(p.tags != null and block.data['tagFilter'] != null and !#strings.isEmpty(block.data['tagFilter']) and p.tags.?[#strings.containsIgnoreCase(',' + #root.block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0)
)}">
<a th:href="@{'/posts/' + ${p.slug}}"
<a th:href="@{'/post/' + ${p.slug}}"
class="news-card"
th:data-category="${p.category != null ? p.category.name : ''}"
th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
@@ -1136,7 +1180,7 @@
if (!grid) return;
const cards = Array.from(grid.querySelectorAll('.news-card'));
const itemsPerPage = Math.min(parseInt(grid.getAttribute('data-items-per-page')) || 12, 12);
const itemsPerPage = parseInt(grid.getAttribute('data-items-per-page')) || 12;
let currentPage = 1;
let activeFilter = 'ALL';
@@ -1268,7 +1312,7 @@
<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(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ','))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (p.tags != null and p.tags.?[#strings.containsIgnoreCase(',' + #root.block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0))}">
<div class="col" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
<a th:href="@{'/posts/' + ${p.slug}}" class="card h-100 border-0 shadow-sm rounded-lg text-decoration-none overflow-hidden group">
<a th:href="@{'/post/' + ${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>
@@ -1299,8 +1343,8 @@
.news-section-v3 .badge-news {
display: inline-block;
padding: 4px 8px;
background-color: #f1f5f9;
color: #475569;
background-color: var(--color-old-brick);
color: white;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
@@ -1333,7 +1377,7 @@
}
.news-section-v3 .img-placeholder-icon {
font-size: 28px;
color: #0284c7;
color: var(--color-old-brick);
opacity: 0.35;
}
.news-section-v3 .featured-img-box img {
@@ -1353,13 +1397,13 @@
.news-section-v3 .featured-title {
font-size: 22px;
font-weight: 700;
color: var(--color-old-brick);
color: black;
line-height: 1.3;
cursor: pointer;
text-decoration: none;
}
.news-section-v3 .featured-title:hover {
color: #0369a1;
color: var(--color-old-brick);
}
.news-section-v3 .featured-excerpt {
font-size: 14px;
@@ -1411,7 +1455,7 @@
margin: 0;
}
.news-section-v3 .small-post:hover .small-title {
color: #0284c7;
color: var(--color-old-brick);
}
@media (max-width: 992px) {
.news-section-v3 .featured-post-wrapper,
@@ -1471,7 +1515,7 @@
<span class="badge-news" th:if="${firstPost.category != null}" th:text="${firstPost.category.name}">Category</span>
<span class="post-date" th:if="${(block.data['showDate'] == null or block.data['showDate'] == true) and firstPost.createdDate != null}" th:text="${#temporals.format(firstPost.createdDate, 'dd/MM/yyyy')}">Date</span>
</div>
<a th:href="@{'/posts/' + ${firstPost.slug}}" class="featured-title" th:utext="${firstPost.title}">Title</a>
<a th:href="@{'/post/' + ${firstPost.slug}}" class="featured-title" th:utext="${firstPost.title}">Title</a>
<p class="featured-excerpt" th:if="${(block.data['showExcerpt'] == null or block.data['showExcerpt'] == true) and firstPost.excerpt != null and !#strings.isEmpty(firstPost.excerpt)}" th:utext="${firstPost.excerpt}">Excerpt</p>
</article>
</th:block>
@@ -1481,7 +1525,7 @@
<div class="side-news-grid-wrapper side-news-grid">
<th:block th:if="${#lists.size(filteredPosts) > 1}">
<th:block th:each="p, iterStat : ${filteredPosts}" th:if="${iterStat.index > 0 and iterStat.index <= 4}">
<a th:href="@{'/posts/' + ${p.slug}}" class="small-post" th:data-category="${p.category != null ? p.category.name : ''}">
<a th:href="@{'/post/' + ${p.slug}}" class="small-post" th:data-category="${p.category != null ? p.category.name : ''}">
<div class="small-img-box" th:if="${block.data['showImage'] == null or block.data['showImage'] == true}">
<img th:if="${p.featuredImage != null and !#strings.isEmpty(p.featuredImage)}" th:src="${p.featuredImage}" th:alt="${p.title}" />
<i th:if="${p.featuredImage == null or #strings.isEmpty(p.featuredImage)}" class="fas fa-newspaper img-placeholder-icon" style="font-size:20px;"></i>
@@ -1509,8 +1553,8 @@
.sis-posts-block .card-body { padding: 16px 18px 20px; display: flex; flex-direction: column; justify-content: space-between; flex-grow: 1; }
.sis-posts-block .card-title { font-size: 15px; font-weight: 700; line-height: 1.45; margin-bottom: 8px; margin-top: 4px; }
.sis-posts-block .card-title a { color: #0f172a !important; text-decoration: none !important; transition: color 0.2s ease; font-weight: 700 !important; }
.sis-posts-block .card-title a:hover { color: #0284c7 !important; text-decoration: none !important; }
.sis-posts-block .badge-primary { background-color: #0284c7 !important; color: #ffffff !important; font-size: 11px; font-weight: 600; padding: 4px 10px; border-radius: 20px; display: inline-block; text-decoration: none !important; }
.sis-posts-block .card-title a:hover { color: var(--color-old-brick) !important; text-decoration: none !important; }
.sis-posts-block .badge-primary { background-color: var(--color-old-brick) !important; color: #ffffff !important; font-size: 11px; font-weight: 600; padding: 4px 10px; border-radius: 20px; display: inline-block; text-decoration: none !important; }
.sis-posts-block .pagination-container,
.sis-posts-block ul.pagination { display: flex !important; justify-content: center !important; align-items: center !important; list-style: none !important; list-style-type: none !important; gap: 8px !important; margin-top: 28px !important; margin-bottom: 0 !important; padding-left: 0 !important; padding-right: 0 !important; width: 100% !important; flex-direction: row !important; }
.sis-posts-block .pagination-container li,
@@ -1519,16 +1563,16 @@
.sis-posts-block .pagination-container .page-link,
.sis-posts-block ul.pagination .page-link { display: inline-flex !important; justify-content: center !important; align-items: center !important; min-width: 38px !important; height: 38px !important; padding: 0 12px !important; border-radius: 8px !important; border: 1px solid #cbd5e1 !important; background-color: #ffffff !important; color: #475569 !important; text-decoration: none !important; font-size: 14px !important; font-weight: 600 !important; transition: all 0.2s ease !important; cursor: pointer !important; user-select: none !important; line-height: 1 !important; }
.sis-posts-block .pagination-container .page-link:hover,
.sis-posts-block ul.pagination .page-link:hover { border-color: #0284c7 !important; color: #0284c7 !important; background-color: #f0f9ff !important; text-decoration: none !important; }
.sis-posts-block ul.pagination .page-link:hover { border-color: var(--color-old-brick) !important; color: var(--color-old-brick) !important; background-color: #f0f9ff !important; text-decoration: none !important; }
.sis-posts-block .pagination-container .page-item.active .page-link,
.sis-posts-block .pagination-container .page-link.active,
.sis-posts-block ul.pagination li.active .page-link { background-color: #0284c7 !important; color: #ffffff !important; border-color: #0284c7 !important; box-shadow: 0 2px 6px rgba(2, 132, 199, 0.3) !important; }
.sis-posts-block ul.pagination li.active .page-link { background-color: var(--color-old-brick) !important; color: #ffffff !important; border-color: var(--color-old-brick) !important; box-shadow: 0 2px 6px rgba(2, 132, 199, 0.3) !important; }
.sis-posts-block .pagination-container .page-item.disabled .page-link,
.sis-posts-block ul.pagination li.disabled .page-link { opacity: 0.45 !important; pointer-events: none !important; cursor: not-allowed !important; }
</style>
<div class="row posts-items-row"
th:classappend="${block.data['displayStyle'] == 'grid' or block.data['displayStyle'] == null ? '' : (block.data['displayStyle'] == 'compact' ? 'flex-column' : '')}"
th:data-limit="${block.data['limit'] != null and block.data['limit'] > 0 ? block.data['limit'] : (block.data['itemsPerPage'] != null and block.data['itemsPerPage'] > 0 ? block.data['itemsPerPage'] : 12)}"
th:data-limit="${block.data['itemsPerPage'] != null and block.data['itemsPerPage'] > 0 ? block.data['itemsPerPage'] : (block.data['limit'] != null and block.data['limit'] > 0 ? block.data['limit'] : 12)}"
th:data-enable-pagination="${block.data['enablePagination'] == null or block.data['enablePagination'] == true}">
<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(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ','))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (p.tags != null and p.tags.?[#strings.containsIgnoreCase(',' + #root.block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0))}">
@@ -1544,7 +1588,7 @@
<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>
<a th:href="@{'/post/' + ${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>
@@ -1651,7 +1695,7 @@
if (fCat) html += '<span class="badge-news">' + fCat + '</span>';
if (showDate && fDate) html += '<span class="post-date">' + fDate + '</span>';
html += '</div>';
html += '<a href="/posts/' + fSlug + '" class="featured-title">' + fTitle + '</a>';
html += '<a href="/post/' + fSlug + '" class="featured-title">' + fTitle + '</a>';
if (showExcerpt && fExcerpt) html += '<p class="featured-excerpt">' + fExcerpt + '</p>';
html += '</article>';
featuredWrapper.innerHTML = html;
@@ -1676,7 +1720,7 @@
const showImage = item.getAttribute('data-show-image') !== 'false';
const showDate = item.getAttribute('data-show-date') !== 'false';
sideHtml += '<a href="/posts/' + sSlug + '" class="small-post" data-category="' + sCat + '">';
sideHtml += '<a href="/post/' + sSlug + '" class="small-post" data-category="' + sCat + '">';
if (showImage) {
sideHtml += '<div class="small-img-box">';
if (sImg) {
@@ -2079,12 +2123,53 @@
<!-- Type 4: Accordion -->
<th:block th:if="${type == 'accordion'}">
<details class="sis-accordion-item mb-3">
<summary class="sis-accordion-title font-weight-bold p-3 bg-light border rounded" style="cursor: pointer; user-select: none">
<span th:utext="${blockData != null and blockData['accTitle' + i] != null ? blockData['accTitle' + i] : (cellObj != null ? cellObj['title'] : '')}">Accordion Title</span>
</summary>
<div class="sis-accordion-body p-3 border border-top-0 rounded-bottom" th:utext="${blockData != null and blockData['accContent' + i] != null ? blockData['accContent' + i] : (cellObj != null ? cellObj['content'] : '')}"></div>
</details>
<th:block th:with="accData=${cellObj != null ? cellObj : blockData}">
<!-- Mode 1: Multi-item Bootstrap accordion inside renderCell -->
<th:block th:if="${accData != null and (accData['layoutStyle'] == 'bootstrap_group' or (accData['items'] != null and !#lists.isEmpty(accData['items'])))}">
<th:block th:with="nestedAccId=${accData['globalId'] != null and !#strings.isEmpty(accData['globalId']) ? accData['globalId'] : ('nested-acc-' + i)}">
<div th:id="${nestedAccId}" class="accordion sis-accordion-wrapper mb-3" th:classappend="${accData['globalClass']}">
<div class="card mb-2" th:each="item, iterStat : ${accData['items']}">
<div class="card-header bg-light p-0" th:id="${nestedAccId + '-head-' + iterStat.index}">
<h5 class="mb-0">
<button class="btn btn-link w-100 text-left font-weight-bold p-3 text-dark d-flex justify-content-between align-items-center sis-accordion-toggle-btn"
type="button"
th:data-target="${'#' + nestedAccId + '-collapse-' + iterStat.index}"
th:data-bs-target="${'#' + nestedAccId + '-collapse-' + iterStat.index}"
th:aria-expanded="${item['isOpen'] == true or (iterStat.first and item['isOpen'] == null) ? 'true' : 'false'}"
th:aria-controls="${nestedAccId + '-collapse-' + iterStat.index}"
th:classappend="${item['isOpen'] == true or (iterStat.first and item['isOpen'] == null) ? '' : 'collapsed'}">
<span th:utext="${item['title'] != null and !#strings.isEmpty(item['title']) ? item['title'] : ('Item #' + iterStat.count)}">Collapsible Group Item</span>
<i class="fas fa-chevron-down small ml-2 text-secondary"></i>
</button>
</h5>
</div>
<div th:id="${nestedAccId + '-collapse-' + iterStat.index}"
class="collapse"
th:classappend="${item['isOpen'] == true or (iterStat.first and item['isOpen'] == null) ? 'show' : ''}"
th:aria-labelledby="${nestedAccId + '-head-' + iterStat.index}"
th:data-parent="${'#' + nestedAccId}"
th:data-bs-parent="${'#' + nestedAccId}">
<div class="card-body p-3 border-top">
<div th:replace=":: renderCell(type=${item['type'] != null ? item['type'] : (item['contentType'] != null ? item['contentType'] : 'html')}, i=${iterStat.index + 1}, blockData=${item})"></div>
</div>
</div>
</div>
</div>
</th:block>
</th:block>
<!-- Mode 2: Single Accordion inside renderCell -->
<th:block th:unless="${accData != null and (accData['layoutStyle'] == 'bootstrap_group' or (accData['items'] != null and !#lists.isEmpty(accData['items'])))}">
<details class="sis-accordion-item mb-3">
<summary class="sis-accordion-title font-weight-bold p-3 bg-light border rounded" style="cursor: pointer; user-select: none">
<span th:utext="${blockData != null and blockData['accTitle' + i] != null ? blockData['accTitle' + i] : (cellObj != null ? cellObj['title'] : '')}">Accordion Title</span>
</summary>
<div class="sis-accordion-body p-3 border border-top-0 rounded-bottom">
<div th:replace=":: renderCell(type=${cellObj != null and cellObj['type'] != null and cellObj['type'] != 'accordion' ? cellObj['type'] : (cellObj != null and cellObj['contentType'] != null and cellObj['contentType'] != 'accordion' ? cellObj['contentType'] : 'html')}, i=${i}, blockData=${cellObj != null ? cellObj : blockData})"></div>
</div>
</details>
</th:block>
</th:block>
</th:block>
<!-- Nested Plugin: Form & Tri Ân (googleForm) -->
@@ -2208,6 +2293,13 @@
</th:block>
</th:block>
<!-- Nested Plugin: tabLivestream -->
<th:block th:if="${type == 'tabLivestream' or type == 'tab-livestream' or type == 'tab_livestream'}">
<th:block th:with="tlsObj=${cellObj != null ? cellObj : (blockData != null ? blockData['col' + i] : null)}">
<div th:if="${tlsObj != null}" th:utext="${hookManager.applyFilters('snippet_content', tlsObj['groupId'] != null ? '[plugin:tabbed-livestream id=&quot;' + tlsObj['groupId'] + '&quot;]' : (tlsObj['items'] != null and !#lists.isEmpty(tlsObj['items']) ? '[plugin:tabbed-livestream id=&quot;' + tlsObj['items'][0]['groupId'] + '&quot;]' : '[plugin:tabbed-livestream]'))}"></div>
</th:block>
</th:block>
<!-- Nested Plugin: timeline -->
<th:block th:if="${type == 'timeline' and ((cellObj != null and cellObj != '' and cellObj['items'] != null) or (blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['items'] != null))}">
<section class="sis-timeline">
@@ -2418,7 +2510,6 @@
<div th:replace=":: renderCell(type=${blockData['subType' + i]}, i=${'sub_' + i}, blockData=${blockData})"></div>
</div>
</th:block>
</th:block>
<style>
html {
scroll-behavior: smooth;
@@ -2622,6 +2713,36 @@
img.style.setProperty('height', hPx, 'important');
}
});
// Bootstrap Accordion Group Collapse Click Handler
document.addEventListener('click', function(e) {
var btn = e.target.closest('.sis-accordion-toggle-btn');
if (!btn) return;
e.preventDefault();
var targetId = btn.getAttribute('data-target') || btn.getAttribute('data-bs-target');
if (!targetId) return;
// Use getElementById to avoid querySelector errors if ID starts with a number
var targetIdClean = targetId.startsWith('#') ? targetId.substring(1) : targetId;
var targetEl = document.getElementById(targetIdClean);
if (!targetEl) return;
var isExpanded = targetEl.classList.contains('show');
console.log(isExpanded);
if (!isExpanded) {
targetEl.classList.add('show');
btn.classList.remove('collapsed');
btn.setAttribute('aria-expanded', 'true');
} else {
targetEl.classList.remove('show');
btn.classList.add('collapsed');
btn.setAttribute('aria-expanded', 'false');
}
});
});
</script>
<!-- Dedicated Page Custom JS -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB