feat: add SISAccordionTool for Editor.js and support multi-item accordion blocks
This commit is contained in:
+3196
-4306
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>
|
||||
|
||||
+11
-7
@@ -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="' + tlsObj['groupId'] + '"]' : (tlsObj['items'] != null and !#lists.isEmpty(tlsObj['items']) ? '[plugin:tabbed-livestream id="' + tlsObj['items'][0]['groupId'] + '"]' : '[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 |
Reference in New Issue
Block a user