feat: add Raw HTML, Accordion, and YouTube plugin support to the editor configuration

This commit is contained in:
2026-08-01 11:53:28 +07:00
parent 4bf6e82f82
commit 2a8c6bfc39
10 changed files with 825 additions and 197 deletions
@@ -922,11 +922,23 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
builtInTools.image = {
class: ImageTool,
config: {
endpoints: { byFile: '/api/manage/media/upload' },
endpoints: {
byFile: '/api/manage/media/upload',
byUrl: '/api/manage/media/fetchUrl'
},
uploader: {
uploadByUrl(url) {
return Promise.resolve({
success: 1,
file: { url: url }
});
}
},
field: 'file',
types: 'image/*'
}
};
window.SISEditorPlugins.image = builtInTools.image;
}
if (typeof AttachesTool !== 'undefined') {
builtInTools.attaches = {
@@ -150,20 +150,21 @@ class FlexTool {
this.wrapper.appendChild(settingsRow);
// Layout Columns Number Input
const countGroup = document.createElement('div');
countGroup.className = 'form-group mb-3';
countGroup.innerHTML = '<label class="small font-weight-bold text-secondary mb-1 d-block">Number of Flex Items (1 - 12)</label>';
// Background Settings Row
const bgSettingsRow = document.createElement('div');
bgSettingsRow.className = 'form-row mb-3 pb-2 border-bottom';
// 1. Flex Items (Cols)
const colDiv = document.createElement('div');
colDiv.className = 'col-md-2 mb-2';
colDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Flex Items</label>';
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm';
input.style.width = '100px';
input.min = '1';
input.max = '12';
input.value = this.data.cols;
if (this.readOnly) input.disabled = true;
input.addEventListener('input', (e) => {
let val = parseInt(e.target.value) || 2;
if (val < 1) val = 1;
@@ -171,9 +172,38 @@ class FlexTool {
this.data.cols = val;
this._renderColumnInputs();
});
colDiv.appendChild(input);
bgSettingsRow.appendChild(colDiv);
countGroup.appendChild(input);
this.wrapper.appendChild(countGroup);
// 2. Global CSS Class
const classDiv = document.createElement('div');
classDiv.className = 'col-md-5 mb-2';
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Flex CSS Class(es)</label>';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.placeholder = 'e.g. my-custom-flex';
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);
bgSettingsRow.appendChild(classDiv);
// 3. Global HTML ID
const idDiv = document.createElement('div');
idDiv.className = 'col-md-5 mb-2';
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Flex HTML ID</label>';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.placeholder = 'e.g. flex-section-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);
bgSettingsRow.appendChild(idDiv);
this.wrapper.appendChild(bgSettingsRow);
// Columns inputs container
this.inputsContainer = document.createElement('div');
@@ -260,8 +290,8 @@ class FlexTool {
if (window.SISEditorPlugins) {
Object.keys(window.SISEditorPlugins).forEach(key => {
// Avoid self-nesting flex to prevent recursion issues
if (key !== 'flex' && !types.some(t => t.value === key)) {
// Allow nesting any registered plugins (no exclusions)
if (!types.some(t => t.value === key)) {
types.push({ value: key, label: `[Plugin] ${key}` });
}
});
@@ -311,24 +341,150 @@ class FlexTool {
// Check if type is a dynamic editor plugin registered in window.SISEditorPlugins
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
try {
const pluginClass = window.SISEditorPlugins[type].class;
let parsedData = {};
if (this.data[`col${i}`]) {
try {
parsedData = typeof this.data[`col${i}`] === 'string' ? JSON.parse(this.data[`col${i}`]) : this.data[`col${i}`];
} catch (e) {
parsedData = { html: this.data[`col${i}`] }; // fallback
}
}
const instance = new pluginClass({
data: parsedData,
api: this.api,
readOnly: this.readOnly
});
const pluginEntry = window.SISEditorPlugins[type];
const pluginClass = pluginEntry.class;
const pluginConfig = pluginEntry.config || {};
let parsedData = {};
if (this.data[`col${i}`]) {
try {
parsedData = typeof this.data[`col${i}`] === 'string' ? JSON.parse(this.data[`col${i}`]) : this.data[`col${i}`];
} catch (e) {
parsedData = { html: this.data[`col${i}`] }; // fallback
}
}
const instance = new pluginClass({
data: parsedData,
api: this.api,
readOnly: this.readOnly,
config: pluginConfig,
block: {
id: 'nested-flex-' + i + '-' + Math.random().toString(36).substring(7),
name: type,
holder: typeContainer,
isEmpty: false,
selected: false,
stretched: false,
tunes: {}
}
});
this.activeInstances[i] = instance;
const element = instance.render();
typeContainer.appendChild(element);
// Fix for Editor.js Image plugin 404 errors (spinner hangs forever)
if (type === 'image') {
// Custom UI override for nested images
const injectCustomButtons = () => {
if (element.querySelector('.sis-custom-image-controls')) return;
const fileBtn = element.querySelector('.cdx-button');
if (fileBtn) {
fileBtn.style.display = 'none'; // Hide native button
}
const controls = document.createElement('div');
controls.className = 'sis-custom-image-controls mt-2 p-2 border rounded bg-light';
controls.style.display = 'flex';
controls.style.flexDirection = 'column';
controls.style.gap = '8px';
controls.innerHTML = `
<div class="text-muted small text-center mb-1"><i class="fas fa-cog"></i> Image Controls</div>
<button type="button" class="btn btn-outline-primary btn-sm w-100" id="btn-upload-img-${i}"><i class="fas fa-upload"></i> Upload Image</button>
<button type="button" class="btn btn-outline-info btn-sm w-100" id="btn-fetch-url-${i}"><i class="fas fa-link"></i> Fetch URL</button>
<button type="button" class="btn btn-outline-secondary btn-sm w-100" id="btn-media-lib-${i}"><i class="fas fa-images"></i> Media Library</button>
<button type="button" class="btn btn-outline-danger btn-sm w-100 mt-2" id="btn-clear-img-${i}"><i class="fas fa-trash"></i> Remove Image</button>
`;
const uploadBtn = controls.querySelector(`#btn-upload-img-${i}`);
if (uploadBtn && fileBtn) {
uploadBtn.addEventListener('click', (e) => {
e.preventDefault();
fileBtn.click();
});
}
const urlBtn = controls.querySelector(`#btn-fetch-url-${i}`);
if (urlBtn) {
urlBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const url = prompt('Enter Image URL:');
if (url && url.trim() !== '') {
this.data[`col${i}`] = {
file: { url: url.trim() },
caption: '',
withBorder: false,
withBackground: false,
stretched: false
};
this._renderTypeSpecificInput(i, container);
}
});
}
const mediaBtn = controls.querySelector(`#btn-media-lib-${i}`);
if (mediaBtn) {
mediaBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof openMediaPickerModal === 'function') {
openMediaPickerModal((url) => {
if (url) {
this.data[`col${i}`] = {
file: { url: url },
caption: '',
withBorder: false,
withBackground: false,
stretched: false
};
this._renderTypeSpecificInput(i, container);
}
});
} else {
alert('Media Library is not yet configured on this system.');
}
});
}
const clearBtn = controls.querySelector(`#btn-clear-img-${i}`);
if (clearBtn) {
clearBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (confirm('Are you sure you want to remove this image?')) {
this.data[`col${i}`] = '';
this._renderTypeSpecificInput(i, container);
}
});
}
element.appendChild(controls);
};
injectCustomButtons();
const handleImageError = () => {
element.classList.remove('image-tool--loading');
element.classList.add('image-tool--empty');
const preloader = element.querySelector('.image-tool__image-preloader');
if (preloader) preloader.style.display = 'none';
};
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.tagName === 'IMG') {
node.addEventListener('error', handleImageError);
}
});
});
});
observer.observe(element, { childList: true, subtree: true });
const existingImg = element.querySelector('img');
if (existingImg) existingImg.addEventListener('error', handleImageError);
}
} catch (err) {
console.error(`Failed to render plugin ${type} inside FlexTool:`, err);
typeContainer.innerHTML = `<div class="alert alert-danger small">Error loading plugin ${type}</div>`;
@@ -553,7 +709,9 @@ class FlexTool {
direction: this.data.direction,
justify: this.data.justify,
align: this.data.align,
gap: this.data.gap
gap: this.data.gap,
globalClass: this.data.globalClass || '',
globalId: this.data.globalId || ''
};
for (let i = 1; i <= this.data.cols; i++) {
const type = this.data[`type${i}`] || 'html';
@@ -44,34 +44,60 @@ class GridTool {
title.innerHTML = '<i class="fas fa-columns"></i> Multi-Column Grid Layout';
this.wrapper.appendChild(title);
// Layout Columns Number Input
const selectorGroup = document.createElement('div');
selectorGroup.className = 'form-group mb-3';
const label = document.createElement('label');
label.className = 'small font-weight-bold text-secondary mb-1 d-block';
label.innerText = 'Number of Columns (1 - 12)';
selectorGroup.appendChild(label);
// Layout Settings Row
const settingsRow = document.createElement('div');
settingsRow.className = 'form-row mb-3 pb-2 border-bottom';
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm';
input.style.width = '100px';
input.min = '1';
input.max = '12';
input.value = this.data.cols;
if (this.readOnly) input.disabled = true;
input.addEventListener('input', (e) => {
// 1. Columns
const colDiv = document.createElement('div');
colDiv.className = 'col-md-2 mb-2';
colDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Columns</label>';
const colInput = document.createElement('input');
colInput.type = 'number';
colInput.className = 'form-control form-control-sm';
colInput.min = '1';
colInput.max = '12';
colInput.value = this.data.cols;
if (this.readOnly) colInput.disabled = true;
colInput.addEventListener('input', (e) => {
let val = parseInt(e.target.value) || 2;
if (val < 1) val = 1;
if (val > 12) val = 12;
this.data.cols = val;
this._renderColumnInputs();
});
colDiv.appendChild(colInput);
settingsRow.appendChild(colDiv);
selectorGroup.appendChild(input);
this.wrapper.appendChild(selectorGroup);
// 2. Global CSS Class
const classDiv = document.createElement('div');
classDiv.className = 'col-md-5 mb-2';
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid CSS Class(es)</label>';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.placeholder = 'e.g. my-custom-grid';
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);
settingsRow.appendChild(classDiv);
// 3. Global HTML ID
const idDiv = document.createElement('div');
idDiv.className = 'col-md-5 mb-2';
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid HTML ID</label>';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.placeholder = 'e.g. grid-section-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);
settingsRow.appendChild(idDiv);
this.wrapper.appendChild(settingsRow);
// Columns inputs container
this.inputsContainer = document.createElement('div');
@@ -160,8 +186,8 @@ class GridTool {
// Dynamically add other registered plugins from window.SISEditorPlugins
if (window.SISEditorPlugins) {
Object.keys(window.SISEditorPlugins).forEach(key => {
// Avoid self-nesting grid to prevent recursion issues
if (key !== 'grid' && !types.some(t => t.value === key)) {
// Allow nesting any registered plugins (no exclusions)
if (!types.some(t => t.value === key)) {
types.push({ value: key, label: `[Plugin] ${key}` });
}
});
@@ -211,7 +237,9 @@ class GridTool {
// Check if type is a dynamic editor plugin registered in window.SISEditorPlugins
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
try {
const pluginClass = window.SISEditorPlugins[type].class;
const pluginEntry = window.SISEditorPlugins[type];
const pluginClass = pluginEntry.class;
const pluginConfig = pluginEntry.config || {};
let parsedData = {};
if (this.data[`col${i}`]) {
try {
@@ -223,12 +251,136 @@ class GridTool {
const instance = new pluginClass({
data: parsedData,
api: this.api,
readOnly: this.readOnly
readOnly: this.readOnly,
config: pluginConfig,
block: {
id: 'nested-grid-' + i + '-' + Math.random().toString(36).substring(7),
name: type,
holder: typeContainer,
isEmpty: false,
selected: false,
stretched: false,
tunes: {}
}
});
this.activeInstances[i] = instance;
const element = instance.render();
typeContainer.appendChild(element);
// Fix for Editor.js Image plugin 404 errors (spinner hangs forever)
if (type === 'image') {
// Custom UI override for nested images
const injectCustomButtons = () => {
if (element.querySelector('.sis-custom-image-controls')) return;
const fileBtn = element.querySelector('.cdx-button');
if (fileBtn) {
fileBtn.style.display = 'none'; // Hide native button
}
const controls = document.createElement('div');
controls.className = 'sis-custom-image-controls mt-2 p-2 border rounded bg-light';
controls.style.display = 'flex';
controls.style.flexDirection = 'column';
controls.style.gap = '8px';
controls.innerHTML = `
<div class="text-muted small text-center mb-1"><i class="fas fa-cog"></i> Image Controls</div>
<button type="button" class="btn btn-outline-primary btn-sm w-100" id="btn-upload-img-${i}"><i class="fas fa-upload"></i> Upload Image</button>
<button type="button" class="btn btn-outline-info btn-sm w-100" id="btn-fetch-url-${i}"><i class="fas fa-link"></i> Fetch URL</button>
<button type="button" class="btn btn-outline-secondary btn-sm w-100" id="btn-media-lib-${i}"><i class="fas fa-images"></i> Media Library</button>
<button type="button" class="btn btn-outline-danger btn-sm w-100 mt-2" id="btn-clear-img-${i}"><i class="fas fa-trash"></i> Remove Image</button>
`;
const uploadBtn = controls.querySelector(`#btn-upload-img-${i}`);
if (uploadBtn && fileBtn) {
uploadBtn.addEventListener('click', (e) => {
e.preventDefault();
fileBtn.click();
});
}
const urlBtn = controls.querySelector(`#btn-fetch-url-${i}`);
if (urlBtn) {
urlBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const url = prompt('Enter Image URL:');
if (url && url.trim() !== '') {
this.data[`col${i}`] = {
file: { url: url.trim() },
caption: '',
withBorder: false,
withBackground: false,
stretched: false
};
this._renderTypeSpecificInput(i, container);
}
});
}
const mediaBtn = controls.querySelector(`#btn-media-lib-${i}`);
if (mediaBtn) {
mediaBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof openMediaPickerModal === 'function') {
openMediaPickerModal((url) => {
if (url) {
this.data[`col${i}`] = {
file: { url: url },
caption: '',
withBorder: false,
withBackground: false,
stretched: false
};
this._renderTypeSpecificInput(i, container);
}
});
} else {
alert('Media Library is not yet configured on this system.');
}
});
}
const clearBtn = controls.querySelector(`#btn-clear-img-${i}`);
if (clearBtn) {
clearBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (confirm('Are you sure you want to remove this image?')) {
this.data[`col${i}`] = '';
this._renderTypeSpecificInput(i, container);
}
});
}
element.appendChild(controls);
};
injectCustomButtons();
const handleImageError = () => {
element.classList.remove('image-tool--loading');
element.classList.add('image-tool--empty');
const preloader = element.querySelector('.image-tool__image-preloader');
if (preloader) preloader.style.display = 'none';
};
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.tagName === 'IMG') {
node.addEventListener('error', handleImageError);
}
});
});
});
observer.observe(element, { childList: true, subtree: true });
const existingImg = element.querySelector('img');
if (existingImg) existingImg.addEventListener('error', handleImageError);
}
} catch (err) {
console.error(`Failed to render plugin ${type} inside GridTool:`, err);
typeContainer.innerHTML = `<div class="alert alert-danger small">Error loading plugin ${type}</div>`;
@@ -449,7 +601,9 @@ class GridTool {
save(blockContent) {
const savedData = {
cols: this.data.cols
cols: this.data.cols,
globalClass: this.data.globalClass || '',
globalId: this.data.globalId || ''
};
for (let i = 1; i <= this.data.cols; i++) {
const type = this.data[`type${i}`] || 'html';
@@ -317,6 +317,9 @@
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="font-weight-bold mb-0"> <i class="fas fa-cubes"></i> Page Content (Block Editor) </label>
<div>
<button type="button" class="btn btn-sm btn-outline-success mr-1" id="exportJsonBtn">
<i class="fas fa-file-export"></i> Export JSON
</button>
<button type="button" class="btn btn-sm btn-outline-info mr-1" id="importJsonBtn">
<i class="fas fa-file-import"></i> Import JSON
</button>
@@ -509,6 +512,43 @@
}
});
// Export JSON Logic
var exportBtn = document.getElementById('exportJsonBtn');
if (exportBtn) {
exportBtn.addEventListener('click', function () {
if (window.sisEditor && typeof window.sisEditor.save === 'function') {
window.sisEditor.save().then(function (outputData) {
var jsonString = JSON.stringify(outputData, null, 2);
var blob = new Blob([jsonString], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
// Get page title for filename if possible, default to editor_backup
var titleInput = document.getElementById('title') || document.querySelector('input[name="title"]');
var filename = 'editor_backup.json';
if (titleInput && titleInput.value.trim()) {
filename = titleInput.value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_') + '_backup.json';
}
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(function () {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 0);
}).catch(function (error) {
console.error('[SIS Editor] Export failed:', error);
alert('Failed to export editor data: ' + error.message);
});
} else {
alert('Editor instance is not initialized or ready.');
}
});
}
// Import JSON Modal Logic
var importBtn = document.getElementById('importJsonBtn');
var importModal = $('#importJsonModal');
@@ -10,6 +10,9 @@
<!-- Add Meta Description for SEO -->
<meta name="description" th:if="${page.metaDescription != null}" th:content="${page.metaDescription}" />
<!-- Load lightweight Bootstrap Grid CSS to support grid/flex block layouts without overriding theme styles -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap-grid.min.css" />
<!-- Customizer CSS overrides -->
<style th:inline="css">
body {
@@ -345,50 +348,64 @@
<!-- 15. Grid / Columns Block -->
<th:block th:if="${block['type'] == 'grid'}">
<div class="container my-4">
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : null}"
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : null}">
<div class="container py-4">
<div class="row">
<th:block th:each="i : ${#numbers.sequence(1, block.data['cols'])}">
<div
th:id="${block.data['id' + i] != null and !#strings.isEmpty(block.data['id' + i])} ? ${block.data['id' + i]} : null"
th:class="${block.data['width' + i] != null and block.data['width' + i] > 0} ? ('col-lg-' + block.data['width' + i] + ' col-md-6 col-12 mb-4') : (${block.data['cols'] == 1} ? 'col-12 mb-4' : (${block.data['cols'] == 2} ? 'col-md-6 col-12 mb-4' : (${block.data['cols'] == 3} ? 'col-lg-4 col-md-6 col-12 mb-4' : (${block.data['cols'] == 4} ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg col-md-6 col-12 mb-4'))))"
th:classappend="${block.data['class' + i] != null and !#strings.isEmpty(block.data['class' + i])} ? ${block.data['class' + i]} : ''"
th:id="${block.data['id' + i] != null and !#strings.isEmpty(block.data['id' + i]) ? block.data['id' + i] : null}"
th:class="${(block.data['width' + i] != null and block.data['width' + i] gt 0) ? ('col-lg-' + block.data['width' + i] + ' col-md-6 col-12 mb-4') : (block.data['cols'] == 1 ? 'col-12 mb-4' : (block.data['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (block.data['cols'] == 3 ? 'col-lg-4 col-md-6 col-12 mb-4' : (block.data['cols'] == 4 ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg col-md-6 col-12 mb-4'))))}"
th:classappend="${block.data['class' + i] != null and !#strings.isEmpty(block.data['class' + i]) ? block.data['class' + i] : ''}"
>
<div th:replace=":: renderCell(type=${block.data['type' + i]}, i=${i}, data=${block.data})"></div>
<div th:replace=":: renderCell(type=${block.data['type' + i]}, i=${i}, blockData=${block.data})"></div>
</div>
</th:block>
</div>
</div>
</div>
</th:block>
<!-- 16. Flex Layout Block -->
<th:block th:if="${block['type'] == 'flex'}">
<div class="container my-4">
<div
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : null}"
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : null}">
<div class="container py-4">
<div
style="display: flex; flex-wrap: wrap;"
th:styleappend="'flex-direction: ' + ${block.data['direction']} + '; justify-content: ' + (${block.data['justify'] == 'start' ? 'flex-start' : (block.data['justify'] == 'end' ? 'flex-end' : (block.data['justify'] == 'between' ? 'space-between' : (block.data['justify'] == 'around' ? 'space-around' : (block.data['justify'] == 'evenly' ? 'space-evenly' : 'center'))))}) + '; align-items: ' + (${block.data['align'] == 'start' ? 'flex-start' : (block.data['align'] == 'end' ? 'flex-end' : block.data['align'])} ) + '; gap: ' + (${block.data['gap'] == '0' ? '0' : (block.data['gap'] == '1' ? '0.25rem' : (block.data['gap'] == '2' ? '0.5rem' : (block.data['gap'] == '3' ? '1rem' : (block.data['gap'] == '4' ? '1.5rem' : '2rem'))))}) + ';'"
th:styleappend="${'flex-direction: ' + (block.data['direction'] != null ? block.data['direction'] : 'row') + '; justify-content: ' + (block.data['justify'] == 'start' ? 'flex-start' : (block.data['justify'] == 'end' ? 'flex-end' : (block.data['justify'] == 'between' ? 'space-between' : (block.data['justify'] == 'around' ? 'space-around' : (block.data['justify'] == 'evenly' ? 'space-evenly' : 'center'))))) + '; align-items: ' + (block.data['align'] == 'start' ? 'flex-start' : (block.data['align'] == 'end' ? 'flex-end' : (block.data['align'] != null ? block.data['align'] : 'stretch'))) + '; gap: ' + (block.data['gap'] == '0' ? '0' : (block.data['gap'] == '1' ? '0.25rem' : (block.data['gap'] == '2' ? '0.5rem' : (block.data['gap'] == '3' ? '1rem' : (block.data['gap'] == '4' ? '1.5rem' : (block.data['gap'] == '5' ? '2rem' : '1rem')))))) + ';'}"
>
<th:block th:each="i : ${#numbers.sequence(1, block.data['cols'])}">
<div
th:id="${block.data['id' + i] != null and !#strings.isEmpty(block.data['id' + i])} ? ${block.data['id' + i]} : null"
th:class="${block.data['width' + i] != null and block.data['width' + i] > 0} ? ('col-lg-' + block.data['width' + i] + ' col-md-6 col-12 mb-4') : (${block.data['cols'] == 1} ? 'col-12 mb-4' : (${block.data['cols'] == 2} ? 'col-md-6 col-12 mb-4' : (${block.data['cols'] == 3} ? 'col-lg-4 col-md-6 col-12 mb-4' : (${block.data['cols'] == 4} ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg col-md-6 col-12 mb-4'))))"
th:classappend="${block.data['class' + i] != null and !#strings.isEmpty(block.data['class' + i])} ? ${block.data['class' + i]} : ''"
th:id="${block.data['id' + i] != null and !#strings.isEmpty(block.data['id' + i]) ? block.data['id' + i] : null}"
th:class="${(block.data['width' + i] != null and block.data['width' + i] gt 0) ? ('col-lg-' + block.data['width' + i] + ' col-md-6 col-12 mb-4') : (block.data['cols'] == 1 ? 'col-12 mb-4' : (block.data['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (block.data['cols'] == 3 ? 'col-lg-4 col-md-6 col-12 mb-4' : (block.data['cols'] == 4 ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg col-md-6 col-12 mb-4'))))}"
th:classappend="${block.data['class' + i] != null and !#strings.isEmpty(block.data['class' + i]) ? block.data['class' + i] : ''}"
>
<div th:replace=":: renderCell(type=${block.data['type' + i]}, i=${i}, data=${block.data})"></div>
<div th:replace=":: renderCell(type=${block.data['type' + i]}, i=${i}, blockData=${block.data})"></div>
</div>
</th:block>
</div>
</div>
</th:block>
</div>
</div>
</th:block>
<!-- Shared Cell Content Renderer Fragment -->
<th:block th:fragment="renderCell(type, i, data)">
<th:block th:fragment="renderCell(type, i, blockData)">
<!-- Type 1: HTML / Text -->
<th:block th:if="${type == null or type == 'html'}" th:utext="${data['col' + i]}"></th:block>
<th:block th:if="${type == null or type == 'html'}" th:utext="${blockData != null ? blockData['col' + i] : ''}"></th:block>
<!-- Type 2: Image -->
<th:block th:if="${type == 'image'}">
<div class="sis-grid-image-wrapper text-center">
<img th:if="${data['imgUrl' + i] != null and !#strings.isEmpty(data['imgUrl' + i])}" th:src="${data['imgUrl' + i]}" class="img-fluid rounded shadow-sm" alt="Grid Image" />
<!-- Native Image Plugin structure -->
<th:block th:if="${blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['file'] != null}">
<img th:src="${blockData['col' + i]['file']['url']}" class="img-fluid rounded shadow-sm" th:alt="${blockData['col' + i]['caption']}" />
</th:block>
<!-- Custom imgUrl fallback -->
<th:block th:unless="${blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['file'] != null}">
<img th:if="${blockData != null and blockData['imgUrl' + i] != null and !#strings.isEmpty(blockData['imgUrl' + i])}" th:src="${blockData['imgUrl' + i]}" class="img-fluid rounded shadow-sm" alt="Grid Image" />
</th:block>
</div>
</th:block>
@@ -397,7 +414,7 @@
<div class="sis-youtube-container text-center">
<div
class="sis-yt-thumb-wrapper"
th:data-embed-url="${data['ytUrl' + i]}"
th:data-embed-url="${blockData != null ? blockData['ytUrl' + i] : ''}"
style="
position: relative;
padding-bottom: 56.25%;
@@ -412,7 +429,7 @@
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
"
th:styleappend="${(data['ytUrl' + i] != null and #strings.contains(data['ytUrl' + i], 'youtu.be/')) ? 'background-image: url(' + 'https://img.youtube.com/vi/' + #strings.substringAfter(data['ytUrl' + i], 'youtu.be/') + '/hqdefault.jpg' + ');' : ((data['ytUrl' + i] != null and #strings.contains(data['ytUrl' + i], 'v=')) ? 'background-image: url(' + 'https://img.youtube.com/vi/' + #strings.substringBefore(#strings.substringAfter(data['ytUrl' + i], 'v='), '&') + '/hqdefault.jpg' + ');' : ((data['ytUrl' + i] != null and #strings.contains(data['ytUrl' + i], 'embed/')) ? 'background-image: url(' + 'https://img.youtube.com/vi/' + #strings.substringBefore(#strings.substringAfter(data['ytUrl' + i], 'embed/'), '?') + '/hqdefault.jpg' + ');' : ''))}"
th:styleappend="${blockData != null and blockData['ytUrl' + i] != null ? ((#strings.contains(blockData['ytUrl' + i], 'youtu.be/')) ? 'background-image: url(' + 'https://img.youtube.com/vi/' + #strings.substringAfter(blockData['ytUrl' + i], 'youtu.be/') + '/hqdefault.jpg' + ');' : ((#strings.contains(blockData['ytUrl' + i], 'v=')) ? 'background-image: url(' + 'https://img.youtube.com/vi/' + #strings.substringBefore(#strings.substringAfter(blockData['ytUrl' + i], 'v='), '&amp;') + '/hqdefault.jpg' + ');' : ((#strings.contains(blockData['ytUrl' + i], 'embed/')) ? 'background-image: url(' + 'https://img.youtube.com/vi/' + #strings.substringBefore(#strings.substringAfter(blockData['ytUrl' + i], 'embed/'), '?') + '/hqdefault.jpg' + ');' : ''))) : ''}"
>
<div class="sis-yt-play-btn" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); transition: transform 0.2s ease">
<svg width="68" height="48" viewBox="0 0 68 48">
@@ -428,24 +445,24 @@
<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="${data['accTitle' + i]}">Accordion Title</span>
<span th:utext="${blockData != null ? blockData['accTitle' + i] : ''}">Accordion Title</span>
</summary>
<div class="sis-accordion-body p-3 border border-top-0 rounded-bottom" th:utext="${data['accContent' + i]}"></div>
<div class="sis-accordion-body p-3 border border-top-0 rounded-bottom" th:utext="${blockData != null ? blockData['accContent' + i] : ''}"></div>
</details>
</th:block>
<!-- Nested Plugin: html-snippet -->
<th:block th:if="${type == 'html-snippet' and data['col' + i] != null and data['col' + i]['id'] != null}">
<div th:replace="~{fragments/snippets :: snippet(id=${data['col' + i]['id']})}"></div>
<th:block th:if="${type == 'html-snippet' and blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['id'] != null}">
<div th:replace="~{fragments/snippets :: snippet(id=${blockData['col' + i]['id']})}"></div>
</th:block>
<!-- Nested Plugin: timeline -->
<th:block th:if="${type == 'timeline' and data['col' + i] != null and data['col' + i]['items'] != null}">
<th:block th:if="${type == 'timeline' and blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['items'] != null}">
<section class="sis-timeline">
<div class="sis-timeline__grid">
<div class="sis-timeline__text-col" style="width: 100%;">
<div class="sis-timeline__line"></div>
<th:block th:each="item : ${data['col' + i]['items']}">
<th:block th:each="item : ${blockData['col' + i]['items']}">
<div class="sis-timeline__entry">
<div class="sis-timeline__dot"></div>
<div class="sis-timeline__entry-content">
@@ -461,8 +478,50 @@
</div>
</section>
</th:block>
<!-- Nested Plugin: grid (Recursive) -->
<th:block th:if="${type == 'grid' and blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['cols'] != null}">
<div th:id="${blockData['col' + i]['globalId'] != null and !#strings.isEmpty(blockData['col' + i]['globalId']) ? blockData['col' + i]['globalId'] : null}"
th:class="${blockData['col' + i]['globalClass'] != null and !#strings.isEmpty(blockData['col' + i]['globalClass']) ? blockData['col' + i]['globalClass'] : null}">
<div class="container-fluid p-0">
<div class="row">
<th:block th:each="nestedI : ${#numbers.sequence(1, blockData['col' + i]['cols'])}">
<div
th:id="${blockData['col' + i]['id' + nestedI] != null and !#strings.isEmpty(blockData['col' + i]['id' + nestedI]) ? blockData['col' + i]['id' + nestedI] : null}"
th:class="${(blockData['col' + i]['width' + nestedI] != null and blockData['col' + i]['width' + nestedI] gt 0) ? ('col-lg-' + blockData['col' + i]['width' + nestedI] + ' col-md-6 col-12 mb-4') : (blockData['col' + i]['cols'] == 1 ? 'col-12 mb-4' : (blockData['col' + i]['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (blockData['col' + i]['cols'] == 3 ? 'col-lg-4 col-md-6 col-12 mb-4' : (blockData['col' + i]['cols'] == 4 ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg col-md-6 col-12 mb-4'))))}"
th:classappend="${blockData['col' + i]['class' + nestedI] != null and !#strings.isEmpty(blockData['col' + i]['class' + nestedI]) ? blockData['col' + i]['class' + nestedI] : ''}"
>
<div th:replace=":: renderCell(type=${blockData['col' + i]['type' + nestedI]}, i=${nestedI}, blockData=${blockData['col' + i]})"></div>
</div>
</th:block>
</div>
</div>
</div>
</th:block>
<!-- Nested Plugin: flex (Recursive) -->
<th:block th:if="${type == 'flex' and blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['cols'] != null}">
<div th:id="${blockData['col' + i]['globalId'] != null and !#strings.isEmpty(blockData['col' + i]['globalId']) ? blockData['col' + i]['globalId'] : null}"
th:class="${blockData['col' + i]['globalClass'] != null and !#strings.isEmpty(blockData['col' + i]['globalClass']) ? blockData['col' + i]['globalClass'] : null}">
<div class="container-fluid p-0">
<div
style="display: flex; flex-wrap: wrap;"
th:styleappend="${'flex-direction: ' + (blockData['col' + i]['direction'] != null ? blockData['col' + i]['direction'] : 'row') + '; justify-content: ' + (blockData['col' + i]['justify'] == 'start' ? 'flex-start' : (blockData['col' + i]['justify'] == 'end' ? 'flex-end' : (blockData['col' + i]['justify'] == 'between' ? 'space-between' : (blockData['col' + i]['justify'] == 'around' ? 'space-around' : (blockData['col' + i]['justify'] == 'evenly' ? 'space-evenly' : 'center'))))) + '; align-items: ' + (blockData['col' + i]['align'] == 'start' ? 'flex-start' : (blockData['col' + i]['align'] == 'end' ? 'flex-end' : (blockData['col' + i]['align'] != null ? blockData['col' + i]['align'] : 'stretch'))) + '; gap: ' + (blockData['col' + i]['gap'] == '0' ? '0' : (blockData['col' + i]['gap'] == '1' ? '0.25rem' : (blockData['col' + i]['gap'] == '2' ? '0.5rem' : (blockData['col' + i]['gap'] == '3' ? '1rem' : (blockData['col' + i]['gap'] == '4' ? '1.5rem' : (blockData['col' + i]['gap'] == '5' ? '2rem' : '1rem')))))) + ';'}"
>
<th:block th:each="nestedI : ${#numbers.sequence(1, blockData['col' + i]['cols'])}">
<div
th:id="${blockData['col' + i]['id' + nestedI] != null and !#strings.isEmpty(blockData['col' + i]['id' + nestedI]) ? blockData['col' + i]['id' + nestedI] : null}"
th:class="${(blockData['col' + i]['width' + nestedI] != null and blockData['col' + i]['width' + nestedI] gt 0) ? ('col-lg-' + blockData['col' + i]['width' + nestedI] + ' col-md-6 col-12 mb-4') : (blockData['col' + i]['cols'] == 1 ? 'col-12 mb-4' : (blockData['col' + i]['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (blockData['col' + i]['cols'] == 3 ? 'col-lg-4 col-md-6 col-12 mb-4' : (blockData['col' + i]['cols'] == 4 ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg col-md-6 col-12 mb-4'))))}"
th:classappend="${blockData['col' + i]['class' + nestedI] != null and !#strings.isEmpty(blockData['col' + i]['class' + nestedI]) ? blockData['col' + i]['class' + nestedI] : ''}"
>
<div th:replace=":: renderCell(type=${blockData['col' + i]['type' + nestedI]}, i=${nestedI}, blockData=${blockData['col' + i]})"></div>
</div>
</th:block>
</div>
</div>
</div>
</th:block>
</th:block>
</th:block>
<style>
html {
scroll-behavior: smooth;