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
+163 -112
View File
@@ -1,7 +1,7 @@
import json
import oracledb
# Construct the Editor.js JSON structure using raw HTML blocks matching ckeditor5-layout schema
# Construct the Editor.js JSON structure using our new structured 'grid' blocks
json_data = {
"time": 1700000000000,
"blocks": [
@@ -21,127 +21,178 @@ json_data = {
}
},
{
"type": "raw",
"type": "grid",
"data": {
"html": """
<section class="relative overflow-hidden xl:py-16 md:py-12 py-8">
<div class="absolute top-0 right-0 w-1/2 h-full bg-primary-100 -skew-x-12 translate-x-1/4 z-0 opacity-50 hidden lg:block"></div>
<div class="container relative z-10">
<div class="ck-layout-columns" columns="2">
<!-- Column 1 (Left): Text -->
<div>
<div class="relative">
<div class="absolute md:-left-4 -left-3 md:-top-5 -top-2 md:size-12 size-8 bg-primary-100 rounded-full"></div>
<h2 class="display-3 text-primary-500 uppercase mb-4 block z-[1] relative">TẦM NHÌN</h2>
<div class="flex flex-col space-y-4">
<div class="bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow">
<p class="body-1 text-gray-800 text-justify !font-medium">Trở thành bệnh viện đại học dẫn đầu Việt Nam đạt chuẩn quốc tế</p>
</div>
</div>
</div>
</div>
<!-- Column 2 (Right): Image -->
<div>
<div class="relative rounded-lg overflow-hidden aspect-[3/2] shadow-lg">
<img alt="vision" src="/images/umass/mission.jpeg" class="w-full h-full object-cover transition-transform duration-700 hover:scale-110" />
</div>
</div>
"cols": 2,
"type1": "html",
"col1": """
<div class="relative pt-4">
<div class="absolute md:-left-4 -left-3 md:-top-5 -top-2 md:size-12 size-8 bg-primary-100 rounded-full"></div>
<h2 class="display-3 text-primary-500 uppercase mb-4 block z-[1] relative">TẦM NHÌN</h2>
<div class="flex flex-col space-y-4">
<div class="bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow">
<p class="body-1 text-gray-800 text-justify !font-medium">Trở thành bệnh viện đại học dẫn đầu Việt Nam đạt chuẩn quốc tế</p>
</div>
</div>
</section>
</div>
""",
"stretched": True
"width1": 6,
"class1": "relative z-10",
"type2": "image",
"col2": {
"file": {
"url": "/images/umass/mission.jpeg"
},
"caption": "",
"withBorder": False,
"withBackground": False,
"stretched": False
},
"width2": 6,
"class2": "shadow-lg rounded"
}
},
{
"type": "raw",
"type": "grid",
"data": {
"html": """
<section class="xl:py-16 md:py-12 py-8 bg-primary-500 text-white overflow-hidden">
<div class="container">
<div class="ck-layout-columns" columns="2">
<!-- Column 1 (Left): Image -->
<div>
<div class="relative h-full">
<div class="relative rounded-lg overflow-hidden aspect-[3/2] shadow-lg">
<img alt="mission" src="/images/umass/vision.jpeg" class="w-full h-full object-cover" />
</div>
</div>
</div>
<!-- Column 2 (Right): Text -->
<div>
<div class="relative">
<div class="absolute md:-left-5 -left-4 md:-top-5 -top-2 md:size-12 size-8 bg-white/50 rounded-full"></div>
<h2 class="display-3 text-white mb-4 relative z-[1]">SỨ MỆNH</h2>
</div>
<div class="bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow">
<p class="body-1 text-primary-500 text-justify !font-medium">Mang đến giải pháp chăm sóc sức khỏe tối ưu từ sự tích hợp giữa điều trị, nghiên cứu và đào tạo</p>
</div>
</div>
</div>
"cols": 2,
"type1": "image",
"col1": {
"file": {
"url": "/images/umass/vision.jpeg"
},
"caption": "",
"withBorder": False,
"withBackground": False,
"stretched": False
},
"width1": 6,
"class1": "shadow-lg rounded",
"type2": "html",
"col2": """
<div class="relative pt-4">
<div class="absolute md:-left-5 -left-4 md:-top-5 -top-2 md:size-12 size-8 bg-primary-100 rounded-full"></div>
<h2 class="display-3 text-primary-500 mb-4 relative z-[1]">SỨ MỆNH</h2>
<div class="bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow">
<p class="body-1 text-primary-500 text-justify !font-medium">Mang đến giải pháp chăm sóc sức khỏe tối ưu từ sự tích hợp giữa điều trị, nghiên cứu và đào tạo</p>
</div>
</section>
</div>
""",
"stretched": True
"width2": 6
}
},
{
"type": "raw",
"type": "grid",
"data": {
"html": """
<section class="bg-primary-75 py-12 overflow-hidden">
<div class="container">
<h2 class="text-center display-4 text-primary-600 xl:mb-6 md:mb-4 mb-3 uppercase">Giá trị cốt lõi</h2>
<div class="ck-layout-columns" columns="4">
<!-- Column 1: Tiên phong -->
<div class="p-3">
<div class="rounded-lg shadow bg-primary-600 text-white p-4 h-full flex flex-col items-center text-center">
<div class="mb-3">
<img alt="Tiên phong" src="/images/career/core-hover-1.webp" style="width: 80px; height: 80px; object-fit: contain;" />
</div>
<h3 class="heading-1 font-weight-bold mb-2">Tiên phong</h3>
<p class="title-1">Phát triển và ứng dụng kỹ thuật tiên tiến vào điều trị, giảng dạy, nghiên cứu</p>
</div>
</div>
<!-- Column 2: Thấu hiểu -->
<div class="p-3">
<div class="rounded-lg shadow bg-white text-primary-600 p-4 h-full flex flex-col items-center text-center border border-primary-100">
<div class="mb-3">
<img alt="Thấu hiểu" src="/images/career/core-2.webp" style="width: 80px; height: 80px; object-fit: contain;" />
</div>
<h3 class="heading-1 font-weight-bold mb-2">Thấu hiểu</h3>
<p class="title-1 text-gray-700">Nỗi đau thể xác và tinh thần của người bệnh để đưa ra giải pháp điều trị tối ưu</p>
</div>
</div>
<!-- Column 3: Chuẩn mực -->
<div class="p-3">
<div class="rounded-lg shadow bg-white text-primary-600 p-4 h-full flex flex-col items-center text-center border border-primary-100">
<div class="mb-3">
<img alt="Chuẩn mực" src="/images/career/core-3.webp" style="width: 80px; height: 80px; object-fit: contain;" />
</div>
<h3 class="heading-1 font-weight-bold mb-2">Chuẩn mực</h3>
<p class="title-1 text-gray-700">Về y đức và học thuật của người thầy giáo thầy thuốc</p>
</div>
</div>
<!-- Column 4: An toàn -->
<div class="p-3">
<div class="rounded-lg shadow bg-white text-primary-600 p-4 h-full flex flex-col items-center text-center border border-primary-100">
<div class="mb-3">
<img alt="An toàn" src="/images/career/core-4.webp" style="width: 80px; height: 80px; object-fit: contain;" />
</div>
<h3 class="heading-1 font-weight-bold mb-2">An toàn</h3>
<p class="title-1 text-gray-700">Cho người bệnh, nhân viên y tế và cộng đồng</p>
</div>
</div>
</div>
</div>
</section>
""",
"stretched": True
"cols": 4,
"type1": "flex",
"col1": {
"cols": 2,
"direction": "column",
"justify": "center",
"align": "center",
"gap": "2",
"type1": "image",
"col1": {
"file": {
"url": "/images/career/core-hover-1.webp"
},
"caption": "",
"withBorder": False,
"withBackground": False,
"stretched": False
},
"type2": "html",
"col2": """
<div class="text-center">
<h3 class="heading-1 font-weight-bold mb-2">Tiên phong</h3>
<p class="title-1">Phát triển và ứng dụng kỹ thuật tiên tiến vào điều trị, giảng dạy, nghiên cứu</p>
</div>
"""
},
"width1": 3,
"class1": "rounded-lg shadow bg-primary-600 text-white p-4 h-full",
"type2": "flex",
"col2": {
"cols": 2,
"direction": "column",
"justify": "center",
"align": "center",
"gap": "2",
"type1": "image",
"col1": {
"file": {
"url": "/images/career/core-2.webp"
},
"caption": "",
"withBorder": False,
"withBackground": False,
"stretched": False
},
"type2": "html",
"col2": """
<div class="text-center">
<h3 class="heading-1 font-weight-bold mb-2">Thấu hiểu</h3>
<p class="title-1 text-gray-700">Nỗi đau thể xác và tinh thần của người bệnh để đưa ra giải pháp điều trị tối ưu</p>
</div>
"""
},
"width2": 3,
"class2": "rounded-lg shadow bg-white text-primary-600 p-4 h-full border border-primary-100",
"type3": "flex",
"col3": {
"cols": 2,
"direction": "column",
"justify": "center",
"align": "center",
"gap": "2",
"type1": "image",
"col1": {
"file": {
"url": "/images/career/core-3.webp"
},
"caption": "",
"withBorder": False,
"withBackground": False,
"stretched": False
},
"type2": "html",
"col2": """
<div class="text-center">
<h3 class="heading-1 font-weight-bold mb-2">Chuẩn mực</h3>
<p class="title-1 text-gray-700">Về y đức và học thuật của người thầy giáo thầy thuốc</p>
</div>
"""
},
"width3": 3,
"class3": "rounded-lg shadow bg-white text-primary-600 p-4 h-full border border-primary-100",
"type4": "flex",
"col4": {
"cols": 2,
"direction": "column",
"justify": "center",
"align": "center",
"gap": "2",
"type1": "image",
"col1": {
"file": {
"url": "/images/career/core-4.webp"
},
"caption": "",
"withBorder": False,
"withBackground": False,
"stretched": False
},
"type2": "html",
"col2": """
<div class="text-center">
<h3 class="heading-1 font-weight-bold mb-2">An toàn</h3>
<p class="title-1 text-gray-700">Cho người bệnh, nhân viên y tế và cộng đồng</p>
</div>
"""
},
"width4": 3,
"class4": "rounded-lg shadow bg-white text-primary-600 p-4 h-full border border-primary-100"
}
}
],
@@ -153,15 +204,15 @@ json_string = json.dumps(json_data, ensure_ascii=False, indent=2)
with open("tam_nhin_su_menh_import.json", "w", encoding="utf-8") as f:
f.write(json_string)
print("Generated tam_nhin_su_menh_import.json successfully!")
print("Generated structured tam_nhin_su_menh_import.json successfully using Grid block configurations!")
# Automatically attempt to update local Oracle Database
# Automatically update Oracle Database
try:
conn = oracledb.connect(user='sisvietnam', password='sisvietnam', dsn='localhost:1521/sisvietnam')
cursor = conn.cursor()
cursor.execute("UPDATE sis_page SET content = :content WHERE slug = 'tam-nhin-su-menh-gia-tri-cot-loi'", content=json_string)
conn.commit()
print("Updated database table 'sis_page' for slug 'tam-nhin-su-menh-gia-tri-cot-loi' with layout JSON blocks!")
print("Updated database table 'sis_page' for slug 'tam-nhin-su-menh-gia-tri-cot-loi' with structured Grid block layouts!")
conn.close()
except Exception as e:
print("DB Update bypassed or failed:", e)
print("DB Update failed:", e)
File diff suppressed because one or more lines are too long
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+158 -9
View File
@@ -2,24 +2,173 @@
"time": 1700000000000,
"blocks": [
{
"type": "raw",
"type": "stickyNav",
"data": {
"html": "\n<section class=\"relative overflow-hidden xl:py-16 md:py-12 py-8\">\n <div class=\"absolute top-0 right-0 w-1/2 h-full bg-primary-100 -skew-x-12 translate-x-1/4 z-0 opacity-50 hidden lg:block\"></div>\n <div class=\"container relative z-10\">\n <div class=\"ck-layout-columns\" columns=\"2\">\n <!-- Column 1 (Left): Text -->\n <div>\n <div class=\"relative\">\n <div class=\"absolute md:-left-4 -left-3 md:-top-5 -top-2 md:size-12 size-8 bg-primary-100 rounded-full\"></div>\n <h2 class=\"display-3 text-primary-500 uppercase mb-4 block z-[1] relative\">TẦM NHÌN</h2>\n <div class=\"flex flex-col space-y-4\">\n <div class=\"bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow\">\n <p class=\"body-1 text-gray-800 text-justify !font-medium\">Trở thành bệnh viện đại học dẫn đầu Việt Nam đạt chuẩn quốc tế</p>\n </div>\n </div>\n </div>\n </div>\n <!-- Column 2 (Right): Image -->\n <div>\n <div class=\"relative rounded-lg overflow-hidden aspect-[3/2] shadow-lg\">\n <img alt=\"vision\" src=\"/images/umass/mission.jpeg\" class=\"w-full h-full object-cover transition-transform duration-700 hover:scale-110\" />\n </div>\n </div>\n </div>\n </div>\n</section>\n",
"stretched": true
"title": "Tầm nhìn - Sứ mệnh",
"bgColor": "#ffffff",
"items": [
{
"label": "Giới thiệu tổng quan",
"link": "/page/gioi-thieu-tong-quan"
},
{
"label": "Lịch sử hình thành",
"link": "/page/lich-su-hinh-thanh"
},
{
"label": "Tầm nhìn - Sứ mệnh - Giá trị cốt lõi",
"link": "/page/tam-nhin-su-menh-gia-tri-cot-loi"
},
{
"label": "Ban lãnh đạo",
"link": "/page/doi-ngu-lanh-dao"
},
{
"label": "Các đơn vị",
"link": "/page/cac-don-vi"
},
{
"label": "Thành tựu và giải thưởng",
"link": "/page/thanh-tuu-va-giai-thuong"
}
]
}
},
{
"type": "raw",
"type": "grid",
"data": {
"html": "\n<section class=\"xl:py-16 md:py-12 py-8 bg-primary-500 text-white overflow-hidden\">\n <div class=\"container\">\n <div class=\"ck-layout-columns\" columns=\"2\">\n <!-- Column 1 (Left): Image -->\n <div>\n <div class=\"relative h-full\">\n <div class=\"relative rounded-lg overflow-hidden aspect-[3/2] shadow-lg\">\n <img alt=\"mission\" src=\"/images/umass/vision.jpeg\" class=\"w-full h-full object-cover\" />\n </div>\n </div>\n </div>\n <!-- Column 2 (Right): Text -->\n <div>\n <div class=\"relative\">\n <div class=\"absolute md:-left-5 -left-4 md:-top-5 -top-2 md:size-12 size-8 bg-white/50 rounded-full\"></div>\n <h2 class=\"display-3 text-white mb-4 relative z-[1]\">SỨ MỆNH</h2>\n </div>\n <div class=\"bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow\">\n <p class=\"body-1 text-primary-500 text-justify !font-medium\">Mang đến giải pháp chăm sóc sức khỏe tối ưu từ sự tích hợp giữa điều trị, nghiên cứu và đào tạo</p>\n </div>\n </div>\n </div>\n </div>\n</section>\n",
"stretched": true
"cols": 2,
"type1": "html",
"col1": "\n<div class=\"relative pt-4\">\n <div class=\"absolute md:-left-4 -left-3 md:-top-5 -top-2 md:size-12 size-8 bg-primary-100 rounded-full\"></div>\n <h2 class=\"display-3 text-primary-500 uppercase mb-4 block z-[1] relative\">TẦM NHÌN</h2>\n <div class=\"flex flex-col space-y-4\">\n <div class=\"bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow\">\n <p class=\"body-1 text-gray-800 text-justify !font-medium\">Trở thành bệnh viện đại học dẫn đầu Việt Nam đạt chuẩn quốc tế</p>\n </div>\n </div>\n</div>\n",
"width1": 6,
"class1": "relative z-10",
"type2": "image",
"col2": {
"file": {
"url": "/images/umass/mission.jpeg"
},
"caption": "",
"withBorder": false,
"withBackground": false,
"stretched": false
},
"width2": 6,
"class2": "shadow-lg rounded"
}
},
{
"type": "raw",
"type": "grid",
"data": {
"html": "\n<section class=\"bg-primary-75 py-12 overflow-hidden\">\n <div class=\"container\">\n <h2 class=\"text-center display-4 text-primary-600 xl:mb-6 md:mb-4 mb-3 uppercase\">Giá trị cốt lõi</h2>\n \n <div class=\"ck-layout-columns\" columns=\"4\">\n <!-- Column 1: Tiên phong -->\n <div class=\"p-3\">\n <div class=\"rounded-lg shadow bg-primary-600 text-white p-4 h-full flex flex-col items-center text-center\">\n <div class=\"mb-3\">\n <img alt=\"Tiên phong\" src=\"/images/career/core-hover-1.webp\" style=\"width: 80px; height: 80px; object-fit: contain;\" />\n </div>\n <h3 class=\"heading-1 font-weight-bold mb-2\">Tiên phong</h3>\n <p class=\"title-1\">Phát triển và ứng dụng kỹ thuật tiên tiến vào điều trị, giảng dạy, nghiên cứu</p>\n </div>\n </div>\n \n <!-- Column 2: Thấu hiểu -->\n <div class=\"p-3\">\n <div class=\"rounded-lg shadow bg-white text-primary-600 p-4 h-full flex flex-col items-center text-center border border-primary-100\">\n <div class=\"mb-3\">\n <img alt=\"Thấu hiểu\" src=\"/images/career/core-2.webp\" style=\"width: 80px; height: 80px; object-fit: contain;\" />\n </div>\n <h3 class=\"heading-1 font-weight-bold mb-2\">Thấu hiểu</h3>\n <p class=\"title-1 text-gray-700\">Nỗi đau thể xác và tinh thần của người bệnh để đưa ra giải pháp điều trị tối ưu</p>\n </div>\n </div>\n \n <!-- Column 3: Chuẩn mực -->\n <div class=\"p-3\">\n <div class=\"rounded-lg shadow bg-white text-primary-600 p-4 h-full flex flex-col items-center text-center border border-primary-100\">\n <div class=\"mb-3\">\n <img alt=\"Chuẩn mực\" src=\"/images/career/core-3.webp\" style=\"width: 80px; height: 80px; object-fit: contain;\" />\n </div>\n <h3 class=\"heading-1 font-weight-bold mb-2\">Chuẩn mực</h3>\n <p class=\"title-1 text-gray-700\">Về y đức và học thuật của người thầy giáo thầy thuốc</p>\n </div>\n </div>\n \n <!-- Column 4: An toàn -->\n <div class=\"p-3\">\n <div class=\"rounded-lg shadow bg-white text-primary-600 p-4 h-full flex flex-col items-center text-center border border-primary-100\">\n <div class=\"mb-3\">\n <img alt=\"An toàn\" src=\"/images/career/core-4.webp\" style=\"width: 80px; height: 80px; object-fit: contain;\" />\n </div>\n <h3 class=\"heading-1 font-weight-bold mb-2\">An toàn</h3>\n <p class=\"title-1 text-gray-700\">Cho người bệnh, nhân viên y tế và cộng đồng</p>\n </div>\n </div>\n </div>\n </div>\n</section>\n",
"stretched": true
"cols": 2,
"type1": "image",
"col1": {
"file": {
"url": "/images/umass/vision.jpeg"
},
"caption": "",
"withBorder": false,
"withBackground": false,
"stretched": false
},
"width1": 6,
"class1": "shadow-lg rounded",
"type2": "html",
"col2": "\n<div class=\"relative pt-4\">\n <div class=\"absolute md:-left-5 -left-4 md:-top-5 -top-2 md:size-12 size-8 bg-primary-100 rounded-full\"></div>\n <h2 class=\"display-3 text-primary-500 mb-4 relative z-[1]\">SỨ MỆNH</h2>\n <div class=\"bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow\">\n <p class=\"body-1 text-primary-500 text-justify !font-medium\">Mang đến giải pháp chăm sóc sức khỏe tối ưu từ sự tích hợp giữa điều trị, nghiên cứu và đào tạo</p>\n </div>\n</div>\n",
"width2": 6
}
},
{
"type": "grid",
"data": {
"cols": 4,
"type1": "flex",
"col1": {
"cols": 2,
"direction": "column",
"justify": "center",
"align": "center",
"gap": "2",
"type1": "image",
"col1": {
"file": {
"url": "/images/career/core-hover-1.webp"
},
"caption": "",
"withBorder": false,
"withBackground": false,
"stretched": false
},
"type2": "html",
"col2": "\n<div class=\"text-center\">\n <h3 class=\"heading-1 font-weight-bold mb-2\">Tiên phong</h3>\n <p class=\"title-1\">Phát triển và ứng dụng kỹ thuật tiên tiến vào điều trị, giảng dạy, nghiên cứu</p>\n</div>\n"
},
"width1": 3,
"class1": "rounded-lg shadow bg-primary-600 text-white p-4 h-full",
"type2": "flex",
"col2": {
"cols": 2,
"direction": "column",
"justify": "center",
"align": "center",
"gap": "2",
"type1": "image",
"col1": {
"file": {
"url": "/images/career/core-2.webp"
},
"caption": "",
"withBorder": false,
"withBackground": false,
"stretched": false
},
"type2": "html",
"col2": "\n<div class=\"text-center\">\n <h3 class=\"heading-1 font-weight-bold mb-2\">Thấu hiểu</h3>\n <p class=\"title-1 text-gray-700\">Nỗi đau thể xác và tinh thần của người bệnh để đưa ra giải pháp điều trị tối ưu</p>\n</div>\n"
},
"width2": 3,
"class2": "rounded-lg shadow bg-white text-primary-600 p-4 h-full border border-primary-100",
"type3": "flex",
"col3": {
"cols": 2,
"direction": "column",
"justify": "center",
"align": "center",
"gap": "2",
"type1": "image",
"col1": {
"file": {
"url": "/images/career/core-3.webp"
},
"caption": "",
"withBorder": false,
"withBackground": false,
"stretched": false
},
"type2": "html",
"col2": "\n<div class=\"text-center\">\n <h3 class=\"heading-1 font-weight-bold mb-2\">Chuẩn mực</h3>\n <p class=\"title-1 text-gray-700\">Về y đức và học thuật của người thầy giáo thầy thuốc</p>\n</div>\n"
},
"width3": 3,
"class3": "rounded-lg shadow bg-white text-primary-600 p-4 h-full border border-primary-100",
"type4": "flex",
"col4": {
"cols": 2,
"direction": "column",
"justify": "center",
"align": "center",
"gap": "2",
"type1": "image",
"col1": {
"file": {
"url": "/images/career/core-4.webp"
},
"caption": "",
"withBorder": false,
"withBackground": false,
"stretched": false
},
"type2": "html",
"col2": "\n<div class=\"text-center\">\n <h3 class=\"heading-1 font-weight-bold mb-2\">An toàn</h3>\n <p class=\"title-1 text-gray-700\">Cho người bệnh, nhân viên y tế và cộng đồng</p>\n</div>\n"
},
"width4": 3,
"class4": "rounded-lg shadow bg-white text-primary-600 p-4 h-full border border-primary-100"
}
}
],