feat: integrate CKEditor layout plugins and add supporting import utilities

This commit is contained in:
2026-08-01 09:27:13 +07:00
parent 1c5d881bca
commit 4bf6e82f82
10 changed files with 3515 additions and 52 deletions
File diff suppressed because one or more lines are too long
+167
View File
@@ -0,0 +1,167 @@
import json
import oracledb
# Construct the Editor.js JSON structure using raw HTML blocks matching ckeditor5-layout schema
json_data = {
"time": 1700000000000,
"blocks": [
{
"type": "stickyNav",
"data": {
"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",
"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>
</div>
</div>
</section>
""",
"stretched": True
}
},
{
"type": "raw",
"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>
</div>
</section>
""",
"stretched": True
}
},
{
"type": "raw",
"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
}
}
],
"version": "2.30.8"
}
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!")
# Automatically attempt to update local 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!")
conn.close()
except Exception as e:
print("DB Update bypassed or failed:", e)
+1033 -1
View File
File diff suppressed because it is too large Load Diff
+1035 -1
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,5 +1,7 @@
{ {
"dependencies": { "dependencies": {
"@packaged-ui/ckeditor5-layout": "github:packaged-ui/ckeditor5-layout",
"@visao/ckeditor5-flex-layout": "^29.1.0",
"cheerio": "^1.2.0" "cheerio": "^1.2.0"
} }
} }
@@ -0,0 +1,591 @@
/**
* Flex block tool for Editor.js.
* Allows users to create highly custom Flexbox layouts with alignment, direction, gaps, and custom column items.
* Supports instantiating other registered plugins inside columns dynamically.
*/
class FlexTool {
static get toolbox() {
return {
title: 'Flex Layout',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line><line x1="15" y1="3" x2="15" y2="21"></line></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
cols: parseInt(data.cols) || 2,
direction: data.direction || 'row',
justify: data.justify || 'start',
align: data.align || 'stretch',
gap: data.gap || '3' // standard Bootstrap gap level
};
this.activeInstances = {};
// Load items content dynamically
for (let i = 1; i <= 12; i++) {
this.data[`type${i}`] = data[`type${i}`] || 'html';
this.data[`col${i}`] = data[`col${i}`] || '';
this.data[`imgUrl${i}`] = data[`imgUrl${i}`] || '';
this.data[`ytUrl${i}`] = data[`ytUrl${i}`] || '';
this.data[`accTitle${i}`] = data[`accTitle${i}`] || '';
this.data[`accContent${i}`] = data[`accContent${i}`] || '';
this.data[`class${i}`] = data[`class${i}`] || '';
this.data[`id${i}`] = data[`id${i}`] || '';
this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto
}
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-flex-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
const title = document.createElement('div');
title.className = 'font-weight-bold text-primary small mb-3';
title.innerHTML = '<i class="fas fa-boxes"></i> Custom Flexbox Layout Builder';
this.wrapper.appendChild(title);
// Container Flex Settings Row
const settingsRow = document.createElement('div');
settingsRow.className = 'form-row mb-3 pb-3 border-bottom';
// 1. Direction
const dirCol = document.createElement('div');
dirCol.className = 'col-md-3 col-sm-6 mb-2';
dirCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Direction</label>';
const dirSelect = document.createElement('select');
dirSelect.className = 'custom-select custom-select-sm';
const dirOpts = [
{ val: 'row', lbl: 'Row' },
{ val: 'row-reverse', lbl: 'Row Reverse' },
{ val: 'column', lbl: 'Column' },
{ val: 'column-reverse', lbl: 'Column Reverse' }
];
dirOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data.direction === o.val;
dirSelect.appendChild(opt);
});
dirSelect.addEventListener('change', (e) => this.data.direction = e.target.value);
dirCol.appendChild(dirSelect);
settingsRow.appendChild(dirCol);
// 2. Justify Content
const justifyCol = document.createElement('div');
justifyCol.className = 'col-md-3 col-sm-6 mb-2';
justifyCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Justify Content</label>';
const justifySelect = document.createElement('select');
justifySelect.className = 'custom-select custom-select-sm';
const justifyOpts = [
{ val: 'start', lbl: 'Start' },
{ val: 'center', lbl: 'Center' },
{ val: 'end', lbl: 'End' },
{ val: 'between', lbl: 'Space Between' },
{ val: 'around', lbl: 'Space Around' },
{ val: 'evenly', lbl: 'Space Evenly' }
];
justifyOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data.justify === o.val;
justifySelect.appendChild(opt);
});
justifySelect.addEventListener('change', (e) => this.data.justify = e.target.value);
justifyCol.appendChild(justifySelect);
settingsRow.appendChild(justifyCol);
// 3. Align Items
const alignCol = document.createElement('div');
alignCol.className = 'col-md-3 col-sm-6 mb-2';
alignCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Align Items</label>';
const alignSelect = document.createElement('select');
alignSelect.className = 'custom-select custom-select-sm';
const alignOpts = [
{ val: 'stretch', lbl: 'Stretch' },
{ val: 'start', lbl: 'Start' },
{ val: 'center', lbl: 'Center' },
{ val: 'end', lbl: 'End' }
];
alignOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data.align === o.val;
alignSelect.appendChild(opt);
});
alignSelect.addEventListener('change', (e) => this.data.align = e.target.value);
alignCol.appendChild(alignSelect);
settingsRow.appendChild(alignCol);
// 4. Gap Level
const gapCol = document.createElement('div');
gapCol.className = 'col-md-3 col-sm-6 mb-2';
gapCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Gap Level</label>';
const gapSelect = document.createElement('select');
gapSelect.className = 'custom-select custom-select-sm';
const gapOpts = [
{ val: '0', lbl: 'None' },
{ val: '1', lbl: 'Extra Small' },
{ val: '2', lbl: 'Small' },
{ val: '3', lbl: 'Medium' },
{ val: '4', lbl: 'Large' },
{ val: '5', lbl: 'Extra Large' }
];
gapOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data.gap === o.val;
gapSelect.appendChild(opt);
});
gapSelect.addEventListener('change', (e) => this.data.gap = e.target.value);
gapCol.appendChild(gapSelect);
settingsRow.appendChild(gapCol);
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>';
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;
if (val > 12) val = 12;
this.data.cols = val;
this._renderColumnInputs();
});
countGroup.appendChild(input);
this.wrapper.appendChild(countGroup);
// Columns inputs container
this.inputsContainer = document.createElement('div');
this.wrapper.appendChild(this.inputsContainer);
this._renderColumnInputs();
return this.wrapper;
}
_renderColumnInputs() {
this.inputsContainer.innerHTML = '';
const row = document.createElement('div');
row.className = 'row';
const count = this.data.cols;
const colWidthClass = count === 1 ? 'col-12' : (count === 2 ? 'col-md-6' : (count === 3 ? 'col-md-4' : (count === 4 ? 'col-md-3' : 'col-md')));
for (let i = 1; i <= count; i++) {
const colDiv = document.createElement('div');
colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white`;
colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';
const headerRow = document.createElement('div');
headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom';
const label = document.createElement('label');
label.className = 'small font-weight-bold text-dark mb-0';
label.innerText = `Item ${i}`;
headerRow.appendChild(label);
const controlsWrapper = document.createElement('div');
controlsWrapper.className = 'd-flex align-items-center gap-1';
// Width Selector
const widthSelect = document.createElement('select');
widthSelect.className = 'custom-select custom-select-sm mr-1';
widthSelect.style.width = '75px';
if (this.readOnly) widthSelect.disabled = true;
const widthOpts = [
{ val: 0, lbl: 'Auto' },
{ val: 1, lbl: '1/12' },
{ val: 2, lbl: '2/12' },
{ val: 3, lbl: '3/12' },
{ val: 4, lbl: '4/12' },
{ val: 5, lbl: '5/12' },
{ val: 6, lbl: '6/12' },
{ val: 7, lbl: '7/12' },
{ val: 8, lbl: '8/12' },
{ val: 9, lbl: '9/12' },
{ val: 10, lbl: '10/12' },
{ val: 11, lbl: '11/12' },
{ val: 12, lbl: '12/12' }
];
widthOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data[`width${i}`] === o.val;
widthSelect.appendChild(opt);
});
widthSelect.addEventListener('change', (e) => {
this.data[`width${i}`] = parseInt(e.target.value) || 0;
});
controlsWrapper.appendChild(widthSelect);
// Column Type Select (Built-ins + Registered plugins dynamically)
const typeSelect = document.createElement('select');
typeSelect.className = 'custom-select custom-select-sm';
typeSelect.style.width = '100px';
if (this.readOnly) typeSelect.disabled = true;
const types = [
{ value: 'html', label: 'HTML/Text' },
{ value: 'image', label: 'Image' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'accordion', label: 'Accordion' }
];
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)) {
types.push({ value: key, label: `[Plugin] ${key}` });
}
});
}
types.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.innerText = t.label;
opt.selected = this.data[`type${i}`] === t.value;
typeSelect.appendChild(opt);
});
const contentDiv = document.createElement('div');
typeSelect.addEventListener('change', (e) => {
this.data[`type${i}`] = e.target.value;
this._renderTypeSpecificInput(i, contentDiv);
});
controlsWrapper.appendChild(typeSelect);
headerRow.appendChild(controlsWrapper);
colDiv.appendChild(headerRow);
colDiv.appendChild(contentDiv);
row.appendChild(colDiv);
this._renderTypeSpecificInput(i, contentDiv);
}
this.inputsContainer.appendChild(row);
}
_renderTypeSpecificInput(i, container) {
container.innerHTML = '';
// Remove tracking of previous instance
if (this.activeInstances[i]) {
delete this.activeInstances[i];
}
const typeContainer = document.createElement('div');
typeContainer.className = 'mb-3';
container.appendChild(typeContainer);
const type = this.data[`type${i}`];
// 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
});
this.activeInstances[i] = instance;
const element = instance.render();
typeContainer.appendChild(element);
} 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>`;
}
} else if (type === 'html') {
const textarea = document.createElement('textarea');
textarea.className = 'form-control form-control-sm';
textarea.style.minHeight = '140px';
textarea.style.fontSize = '12px';
textarea.placeholder = `HTML / Text for Item ${i}...`;
textarea.value = typeof this.data[`col${i}`] === 'object' ? JSON.stringify(this.data[`col${i}`]) : this.data[`col${i}`] || '';
if (this.readOnly) textarea.disabled = true;
textarea.addEventListener('input', (e) => {
this.data[`col${i}`] = e.target.value;
});
typeContainer.appendChild(textarea);
} else if (type === 'image') {
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group input-group-sm mb-2';
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm';
input.placeholder = 'Paste image URL here...';
input.value = this.data[`imgUrl${i}`] || '';
if (this.readOnly) input.disabled = true;
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const chooseBtn = document.createElement('button');
chooseBtn.type = 'button';
chooseBtn.className = 'btn btn-outline-secondary';
chooseBtn.innerText = 'Choose';
if (this.readOnly) chooseBtn.disabled = true;
chooseBtn.addEventListener('click', () => {
if (typeof openMediaPickerModal === 'function') {
openMediaPickerModal((selectedUrl) => {
input.value = selectedUrl;
this.data[`imgUrl${i}`] = selectedUrl;
previewImg.src = selectedUrl;
previewBox.style.display = 'block';
});
} else {
alert('Media Picker modal helper is not loaded.');
}
});
const uploadBtn = document.createElement('button');
uploadBtn.type = 'button';
uploadBtn.className = 'btn btn-outline-primary';
uploadBtn.innerText = 'Upload';
if (this.readOnly) uploadBtn.disabled = true;
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
uploadBtn.disabled = true;
uploadBtn.innerText = '...';
try {
const response = await fetch('/api/manage/media/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success && result.file && result.file.url) {
const url = result.file.url;
input.value = url;
this.data[`imgUrl${i}`] = url;
previewImg.src = url;
previewBox.style.display = 'block';
} else {
alert('Upload failed: ' + (result.message || 'Unknown error'));
}
} catch (err) {
console.error(err);
alert('Upload error occurred.');
} finally {
uploadBtn.disabled = false;
uploadBtn.innerText = 'Upload';
}
});
appendDiv.appendChild(chooseBtn);
appendDiv.appendChild(uploadBtn);
inputGroup.appendChild(input);
inputGroup.appendChild(appendDiv);
typeContainer.appendChild(inputGroup);
typeContainer.appendChild(fileInput);
const previewBox = document.createElement('div');
previewBox.className = 'border rounded p-1 text-center bg-light';
previewBox.style.display = this.data[`imgUrl${i}`] ? 'block' : 'none';
previewBox.style.maxHeight = '150px';
previewBox.style.overflow = 'hidden';
const previewImg = document.createElement('img');
previewImg.src = this.data[`imgUrl${i}`] || '';
previewImg.style.maxWidth = '100%';
previewImg.style.maxHeight = '140px';
previewImg.style.objectFit = 'contain';
previewBox.appendChild(previewImg);
typeContainer.appendChild(previewBox);
input.addEventListener('input', (e) => {
const val = e.target.value.trim();
this.data[`imgUrl${i}`] = val;
if (val) {
previewImg.src = val;
previewBox.style.display = 'block';
} else {
previewBox.style.display = 'none';
}
});
} else if (type === 'youtube') {
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm mb-2';
input.placeholder = 'Paste YouTube video URL...';
input.value = this.data[`ytUrl${i}`] || '';
if (this.readOnly) input.disabled = true;
const preview = document.createElement('div');
preview.className = 'small text-muted p-2 bg-light border rounded';
preview.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX';
input.addEventListener('input', (e) => {
this.data[`ytUrl${i}`] = e.target.value.trim();
});
typeContainer.appendChild(input);
typeContainer.appendChild(preview);
} else if (type === 'accordion') {
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.className = 'form-control form-control-sm mb-2';
titleInput.placeholder = 'Accordion Title...';
titleInput.value = this.data[`accTitle${i}`] || '';
if (this.readOnly) titleInput.disabled = true;
const contentTextarea = document.createElement('textarea');
contentTextarea.className = 'form-control form-control-sm';
contentTextarea.style.minHeight = '90px';
contentTextarea.style.fontSize = '12px';
contentTextarea.placeholder = 'Accordion Body HTML...';
contentTextarea.value = this.data[`accContent${i}`] || '';
if (this.readOnly) contentTextarea.disabled = true;
titleInput.addEventListener('input', (e) => {
this.data[`accTitle${i}`] = e.target.value;
});
contentTextarea.addEventListener('input', (e) => {
this.data[`accContent${i}`] = e.target.value;
});
typeContainer.appendChild(titleInput);
typeContainer.appendChild(contentTextarea);
}
// Advanced Settings (ID and Class)
const advancedHeader = document.createElement('div');
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
advancedHeader.style.fontSize = '10px';
advancedHeader.innerText = 'ADVANCED SETTINGS';
container.appendChild(advancedHeader);
const advRow = document.createElement('div');
advRow.className = 'form-row';
const classCol = document.createElement('div');
classCol.className = 'col';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.style.fontSize = '11px';
classInput.placeholder = 'CSS Class(es)...';
classInput.value = this.data[`class${i}`] || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => {
this.data[`class${i}`] = e.target.value.trim();
});
classCol.appendChild(classInput);
const idCol = document.createElement('div');
idCol.className = 'col';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.style.fontSize = '11px';
idInput.placeholder = 'HTML ID...';
idInput.value = this.data[`id${i}`] || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => {
this.data[`id${i}`] = e.target.value.trim();
});
idCol.appendChild(idInput);
advRow.appendChild(classCol);
advRow.appendChild(idCol);
container.appendChild(advRow);
}
save(blockContent) {
const savedData = {
cols: this.data.cols,
direction: this.data.direction,
justify: this.data.justify,
align: this.data.align,
gap: this.data.gap
};
for (let i = 1; i <= this.data.cols; i++) {
const type = this.data[`type${i}`] || 'html';
savedData[`type${i}`] = type;
// If it is a dynamically nested plugin, save its output data
if (this.activeInstances[i]) {
try {
const pluginData = this.activeInstances[i].save();
savedData[`col${i}`] = pluginData;
} catch (e) {
console.error(`Failed to save plugin ${type} inside FlexTool:`, e);
savedData[`col${i}`] = this.data[`col${i}`];
}
} else {
savedData[`col${i}`] = this.data[`col${i}`] || '';
}
savedData[`imgUrl${i}`] = this.data[`imgUrl${i}`] || '';
savedData[`ytUrl${i}`] = this.data[`ytUrl${i}`] || '';
savedData[`accTitle${i}`] = this.data[`accTitle${i}`] || '';
savedData[`accContent${i}`] = this.data[`accContent${i}`] || '';
savedData[`class${i}`] = this.data[`class${i}`] || '';
savedData[`id${i}`] = this.data[`id${i}`] || '';
savedData[`width${i}`] = this.data[`width${i}`] || 0;
}
return savedData;
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['flex'] = {
class: FlexTool
};
@@ -0,0 +1,487 @@
/**
* Grid block tool for Editor.js.
* Allows users to create responsive layouts with any number of columns and choose different content types, manual widths,
* and dynamically instantiates other registered editor plugins inside columns.
*/
class GridTool {
static get toolbox() {
return {
title: 'Grid / Columns',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"></rect><rect x="14" y="3" width="7" height="9"></rect><rect x="14" y="15" width="7" height="6"></rect><rect x="3" y="15" width="7" height="6"></rect></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
cols: parseInt(data.cols) || 2
};
this.activeInstances = {};
// Load column contents dynamically
for (let i = 1; i <= 12; i++) {
this.data[`type${i}`] = data[`type${i}`] || 'html';
this.data[`col${i}`] = data[`col${i}`] || '';
this.data[`imgUrl${i}`] = data[`imgUrl${i}`] || '';
this.data[`ytUrl${i}`] = data[`ytUrl${i}`] || '';
this.data[`accTitle${i}`] = data[`accTitle${i}`] || '';
this.data[`accContent${i}`] = data[`accContent${i}`] || '';
this.data[`class${i}`] = data[`class${i}`] || '';
this.data[`id${i}`] = data[`id${i}`] || '';
this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto
}
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-grid-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
const title = document.createElement('div');
title.className = 'font-weight-bold text-primary small mb-3';
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);
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;
if (val > 12) val = 12;
this.data.cols = val;
this._renderColumnInputs();
});
selectorGroup.appendChild(input);
this.wrapper.appendChild(selectorGroup);
// Columns inputs container
this.inputsContainer = document.createElement('div');
this.wrapper.appendChild(this.inputsContainer);
this._renderColumnInputs();
return this.wrapper;
}
_renderColumnInputs() {
this.inputsContainer.innerHTML = '';
const row = document.createElement('div');
row.className = 'row';
const count = this.data.cols;
const colWidthClass = count === 1 ? 'col-12' : (count === 2 ? 'col-md-6' : (count === 3 ? 'col-md-4' : (count === 4 ? 'col-md-3' : 'col-md')));
for (let i = 1; i <= count; i++) {
const colDiv = document.createElement('div');
colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white`;
colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';
const headerRow = document.createElement('div');
headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom';
const label = document.createElement('label');
label.className = 'small font-weight-bold text-dark mb-0';
label.innerText = `Col ${i}`;
headerRow.appendChild(label);
const controlsWrapper = document.createElement('div');
controlsWrapper.className = 'd-flex align-items-center gap-1';
// Width Selector
const widthSelect = document.createElement('select');
widthSelect.className = 'custom-select custom-select-sm mr-1';
widthSelect.style.width = '75px';
if (this.readOnly) widthSelect.disabled = true;
const widthOpts = [
{ val: 0, lbl: 'Auto' },
{ val: 1, lbl: '1/12' },
{ val: 2, lbl: '2/12' },
{ val: 3, lbl: '3/12' },
{ val: 4, lbl: '4/12' },
{ val: 5, lbl: '5/12' },
{ val: 6, lbl: '6/12' },
{ val: 7, lbl: '7/12' },
{ val: 8, lbl: '8/12' },
{ val: 9, lbl: '9/12' },
{ val: 10, lbl: '10/12' },
{ val: 11, lbl: '11/12' },
{ val: 12, lbl: '12/12' }
];
widthOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data[`width${i}`] === o.val;
widthSelect.appendChild(opt);
});
widthSelect.addEventListener('change', (e) => {
this.data[`width${i}`] = parseInt(e.target.value) || 0;
});
controlsWrapper.appendChild(widthSelect);
// Column Type Select (Built-ins + Registered plugins dynamically)
const typeSelect = document.createElement('select');
typeSelect.className = 'custom-select custom-select-sm';
typeSelect.style.width = '100px';
if (this.readOnly) typeSelect.disabled = true;
// Base types
const types = [
{ value: 'html', label: 'HTML/Text' },
{ value: 'image', label: 'Image' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'accordion', label: 'Accordion' }
];
// 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)) {
types.push({ value: key, label: `[Plugin] ${key}` });
}
});
}
types.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.innerText = t.label;
opt.selected = this.data[`type${i}`] === t.value;
typeSelect.appendChild(opt);
});
const contentDiv = document.createElement('div');
typeSelect.addEventListener('change', (e) => {
this.data[`type${i}`] = e.target.value;
this._renderTypeSpecificInput(i, contentDiv);
});
controlsWrapper.appendChild(typeSelect);
headerRow.appendChild(controlsWrapper);
colDiv.appendChild(headerRow);
colDiv.appendChild(contentDiv);
row.appendChild(colDiv);
this._renderTypeSpecificInput(i, contentDiv);
}
this.inputsContainer.appendChild(row);
}
_renderTypeSpecificInput(i, container) {
container.innerHTML = '';
// Remove tracking of previous instance
if (this.activeInstances[i]) {
delete this.activeInstances[i];
}
const typeContainer = document.createElement('div');
typeContainer.className = 'mb-3';
container.appendChild(typeContainer);
const type = this.data[`type${i}`];
// 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
});
this.activeInstances[i] = instance;
const element = instance.render();
typeContainer.appendChild(element);
} 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>`;
}
} else if (type === 'html') {
const textarea = document.createElement('textarea');
textarea.className = 'form-control form-control-sm';
textarea.style.minHeight = '140px';
textarea.style.fontSize = '12px';
textarea.placeholder = `HTML / Text for Column ${i}...`;
textarea.value = typeof this.data[`col${i}`] === 'object' ? JSON.stringify(this.data[`col${i}`]) : this.data[`col${i}`] || '';
if (this.readOnly) textarea.disabled = true;
textarea.addEventListener('input', (e) => {
this.data[`col${i}`] = e.target.value;
});
typeContainer.appendChild(textarea);
} else if (type === 'image') {
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group input-group-sm mb-2';
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm';
input.placeholder = 'Paste image URL here...';
input.value = this.data[`imgUrl${i}`] || '';
if (this.readOnly) input.disabled = true;
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const chooseBtn = document.createElement('button');
chooseBtn.type = 'button';
chooseBtn.className = 'btn btn-outline-secondary';
chooseBtn.innerText = 'Choose';
if (this.readOnly) chooseBtn.disabled = true;
chooseBtn.addEventListener('click', () => {
if (typeof openMediaPickerModal === 'function') {
openMediaPickerModal((selectedUrl) => {
input.value = selectedUrl;
this.data[`imgUrl${i}`] = selectedUrl;
previewImg.src = selectedUrl;
previewBox.style.display = 'block';
});
} else {
alert('Media Picker modal helper is not loaded.');
}
});
const uploadBtn = document.createElement('button');
uploadBtn.type = 'button';
uploadBtn.className = 'btn btn-outline-primary';
uploadBtn.innerText = 'Upload';
if (this.readOnly) uploadBtn.disabled = true;
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
uploadBtn.disabled = true;
uploadBtn.innerText = '...';
try {
const response = await fetch('/api/manage/media/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success && result.file && result.file.url) {
const url = result.file.url;
input.value = url;
this.data[`imgUrl${i}`] = url;
previewImg.src = url;
previewBox.style.display = 'block';
} else {
alert('Upload failed: ' + (result.message || 'Unknown error'));
}
} catch (err) {
console.error(err);
alert('Upload error occurred.');
} finally {
uploadBtn.disabled = false;
uploadBtn.innerText = 'Upload';
}
});
appendDiv.appendChild(chooseBtn);
appendDiv.appendChild(uploadBtn);
inputGroup.appendChild(input);
inputGroup.appendChild(appendDiv);
typeContainer.appendChild(inputGroup);
typeContainer.appendChild(fileInput);
const previewBox = document.createElement('div');
previewBox.className = 'border rounded p-1 text-center bg-light';
previewBox.style.display = this.data[`imgUrl${i}`] ? 'block' : 'none';
previewBox.style.maxHeight = '150px';
previewBox.style.overflow = 'hidden';
const previewImg = document.createElement('img');
previewImg.src = this.data[`imgUrl${i}`] || '';
previewImg.style.maxWidth = '100%';
previewImg.style.maxHeight = '140px';
previewImg.style.objectFit = 'contain';
previewBox.appendChild(previewImg);
typeContainer.appendChild(previewBox);
input.addEventListener('input', (e) => {
const val = e.target.value.trim();
this.data[`imgUrl${i}`] = val;
if (val) {
previewImg.src = val;
previewBox.style.display = 'block';
} else {
previewBox.style.display = 'none';
}
});
} else if (type === 'youtube') {
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm mb-2';
input.placeholder = 'Paste YouTube video URL...';
input.value = this.data[`ytUrl${i}`] || '';
if (this.readOnly) input.disabled = true;
const preview = document.createElement('div');
preview.className = 'small text-muted p-2 bg-light border rounded';
preview.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX';
input.addEventListener('input', (e) => {
this.data[`ytUrl${i}`] = e.target.value.trim();
});
typeContainer.appendChild(input);
typeContainer.appendChild(preview);
} else if (type === 'accordion') {
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.className = 'form-control form-control-sm mb-2';
titleInput.placeholder = 'Accordion Title...';
titleInput.value = this.data[`accTitle${i}`] || '';
if (this.readOnly) titleInput.disabled = true;
const contentTextarea = document.createElement('textarea');
contentTextarea.className = 'form-control form-control-sm';
contentTextarea.style.minHeight = '90px';
contentTextarea.style.fontSize = '12px';
contentTextarea.placeholder = 'Accordion Body HTML...';
contentTextarea.value = this.data[`accContent${i}`] || '';
if (this.readOnly) contentTextarea.disabled = true;
titleInput.addEventListener('input', (e) => {
this.data[`accTitle${i}`] = e.target.value;
});
contentTextarea.addEventListener('input', (e) => {
this.data[`accContent${i}`] = e.target.value;
});
typeContainer.appendChild(titleInput);
typeContainer.appendChild(contentTextarea);
}
// Advanced Settings (ID and Class)
const advancedHeader = document.createElement('div');
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
advancedHeader.style.fontSize = '10px';
advancedHeader.innerText = 'ADVANCED SETTINGS';
container.appendChild(advancedHeader);
const advRow = document.createElement('div');
advRow.className = 'form-row';
const classCol = document.createElement('div');
classCol.className = 'col';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.style.fontSize = '11px';
classInput.placeholder = 'CSS Class(es)...';
classInput.value = this.data[`class${i}`] || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => {
this.data[`class${i}`] = e.target.value.trim();
});
classCol.appendChild(classInput);
const idCol = document.createElement('div');
idCol.className = 'col';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.style.fontSize = '11px';
idInput.placeholder = 'HTML ID...';
idInput.value = this.data[`id${i}`] || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => {
this.data[`id${i}`] = e.target.value.trim();
});
idCol.appendChild(idInput);
advRow.appendChild(classCol);
advRow.appendChild(idCol);
container.appendChild(advRow);
}
save(blockContent) {
const savedData = {
cols: this.data.cols
};
for (let i = 1; i <= this.data.cols; i++) {
const type = this.data[`type${i}`] || 'html';
savedData[`type${i}`] = type;
// If it is a dynamically nested plugin, save its output data
if (this.activeInstances[i]) {
try {
const pluginData = this.activeInstances[i].save();
savedData[`col${i}`] = pluginData;
} catch (e) {
console.error(`Failed to save plugin ${type} inside GridTool:`, e);
savedData[`col${i}`] = this.data[`col${i}`];
}
} else {
savedData[`col${i}`] = this.data[`col${i}`] || '';
}
savedData[`imgUrl${i}`] = this.data[`imgUrl${i}`] || '';
savedData[`ytUrl${i}`] = this.data[`ytUrl${i}`] || '';
savedData[`accTitle${i}`] = this.data[`accTitle${i}`] || '';
savedData[`accContent${i}`] = this.data[`accContent${i}`] || '';
savedData[`class${i}`] = this.data[`class${i}`] || '';
savedData[`id${i}`] = this.data[`id${i}`] || '';
savedData[`width${i}`] = this.data[`width${i}`] || 0;
}
return savedData;
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['grid'] = {
class: GridTool
};
@@ -429,6 +429,8 @@
<!-- Custom SIS Editor Plugins --> <!-- Custom SIS Editor Plugins -->
<script th:src="@{/js/manage/editor-plugins/html-snippet.js}"></script> <script th:src="@{/js/manage/editor-plugins/html-snippet.js}"></script>
<script th:src="@{/js/manage/editor-plugins/timeline.js}"></script> <script th:src="@{/js/manage/editor-plugins/timeline.js}"></script>
<script th:src="@{/js/manage/editor-plugins/grid.js}"></script>
<script th:src="@{/js/manage/editor-plugins/flex.js}"></script>
<!-- Init Editor --> <!-- Init Editor -->
<script th:src="@{/js/manage/editor-config.js}"></script> <script th:src="@{/js/manage/editor-config.js}"></script>
@@ -290,57 +290,178 @@
<img th:src="${item['imageUrl']}" alt="Timeline Image" loading="lazy" /> <img th:src="${item['imageUrl']}" alt="Timeline Image" loading="lazy" />
</div> </div>
</div> </div>
<div class="sis-timeline__img-slot sis-timeline__img-slot-empty" th:unless="${item['imageUrl'] != null and !#strings.isEmpty(item['imageUrl'])}"> <div
</div> class="sis-timeline__img-slot sis-timeline__img-slot-empty"
th:unless="${item['imageUrl'] != null and !#strings.isEmpty(item['imageUrl'])}"
></div>
</th:block> </th:block>
</div> </div>
</div> </div>
</section> </section>
<script th:inline="javascript"> <script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
const syncTimelineHeights = () => { const syncTimelineHeights = () => {
if (window.innerWidth < 1024) { if (window.innerWidth < 1024) {
document.querySelectorAll('.sis-timeline__entry, .sis-timeline__img-slot').forEach(el => { document.querySelectorAll('.sis-timeline__entry, .sis-timeline__img-slot').forEach(el => {
el.style.minHeight = ''; el.style.minHeight = '';
el.style.marginBottom = ''; el.style.marginBottom = '';
}); });
return; return;
} }
// Use the specific timeline section ID in case there are multiple // Use the specific timeline section ID in case there are multiple
const section = document.querySelector('#timeline-[[${stat != null ? stat.index : 0}]]'); const section = document.querySelector('#timeline-[[${stat != null ? stat.index : 0}]]');
if (!section) return; if (!section) return;
const texts = section.querySelectorAll('.sis-timeline__entry'); const texts = section.querySelectorAll('.sis-timeline__entry');
const imgs = section.querySelectorAll('.sis-timeline__img-slot'); const imgs = section.querySelectorAll('.sis-timeline__img-slot');
for (let i = 0; i < texts.length; i++) { for (let i = 0; i < texts.length; i++) {
if (imgs[i]) { if (imgs[i]) {
// reset inline styles first to measure natural height // reset inline styles first to measure natural height
texts[i].style.minHeight = ''; texts[i].style.minHeight = '';
texts[i].style.marginBottom = ''; texts[i].style.marginBottom = '';
imgs[i].style.minHeight = ''; imgs[i].style.minHeight = '';
imgs[i].style.marginBottom = ''; imgs[i].style.marginBottom = '';
const textH = texts[i].getBoundingClientRect().height; const textH = texts[i].getBoundingClientRect().height;
const imgH = imgs[i].getBoundingClientRect().height; const imgH = imgs[i].getBoundingClientRect().height;
const maxH = Math.max(textH, imgH); const maxH = Math.max(textH, imgH);
texts[i].style.minHeight = maxH + 'px'; texts[i].style.minHeight = maxH + 'px';
imgs[i].style.minHeight = maxH + 'px'; imgs[i].style.minHeight = maxH + 'px';
// sync bottom margin so they space out identically // sync bottom margin so they space out identically
texts[i].style.marginBottom = '4rem'; texts[i].style.marginBottom = '4rem';
imgs[i].style.marginBottom = '4rem'; imgs[i].style.marginBottom = '4rem';
} }
} }
}; };
syncTimelineHeights(); syncTimelineHeights();
window.addEventListener('resize', syncTimelineHeights); window.addEventListener('resize', syncTimelineHeights);
// Run once more after images load // Run once more after images load
window.addEventListener('load', syncTimelineHeights); window.addEventListener('load', syncTimelineHeights);
}); });
</script> </script>
</th:block> </th:block>
</div>
<!-- 15. Grid / Columns Block -->
<th:block th:if="${block['type'] == 'grid'}">
<div class="container my-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]} : ''"
>
<div th:replace=":: renderCell(type=${block.data['type' + i]}, i=${i}, data=${block.data})"></div>
</div>
</th:block>
</div>
</div>
</th:block>
<!-- 16. Flex Layout Block -->
<th:block th:if="${block['type'] == 'flex'}">
<div class="container my-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: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]} : ''"
>
<div th:replace=":: renderCell(type=${block.data['type' + i]}, i=${i}, data=${block.data})"></div>
</div>
</th:block>
</div>
</div>
</th:block>
<!-- Shared Cell Content Renderer Fragment -->
<th:block th:fragment="renderCell(type, i, data)">
<!-- Type 1: HTML / Text -->
<th:block th:if="${type == null or type == 'html'}" th:utext="${data['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" />
</div>
</th:block>
<!-- Type 3: YouTube Video -->
<th:block th:if="${type == 'youtube'}">
<div class="sis-youtube-container text-center">
<div
class="sis-yt-thumb-wrapper"
th:data-embed-url="${data['ytUrl' + i]}"
style="
position: relative;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;
border-radius: 8px;
background-color: #111;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
margin: 0 auto;
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' + ');' : ''))}"
>
<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">
<path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="#f00"></path>
<polygon points="26,12 26,36 48,24" fill="#fff"></polygon>
</svg>
</div>
</div>
</div>
</th:block>
<!-- 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="${data['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>
</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>
<!-- Nested Plugin: timeline -->
<th:block th:if="${type == 'timeline' and data['col' + i] != null and data['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']}">
<div class="sis-timeline__entry">
<div class="sis-timeline__dot"></div>
<div class="sis-timeline__entry-content">
<div class="sis-timeline__date" th:utext="${item['date']}"></div>
<div class="sis-timeline__desc" th:utext="${item['description']}"></div>
<div class="sis-timeline__mobile-img" th:if="${item['imageUrl'] != null and !#strings.isEmpty(item['imageUrl'])}">
<img th:src="${item['imageUrl']}" alt="Timeline Image" loading="lazy" />
</div>
</div>
</div>
</th:block>
</div>
</div>
</section>
</th:block>
</th:block>
</th:block> </th:block>
<style> <style>
html { html {
+27
View File
@@ -0,0 +1,27 @@
{
"time": 1700000000000,
"blocks": [
{
"type": "raw",
"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
}
},
{
"type": "raw",
"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
}
},
{
"type": "raw",
"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
}
}
],
"version": "2.30.8"
}