feat: integrate CKEditor layout plugins and add supporting import utilities
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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
File diff suppressed because it is too large
Load Diff
Generated
+1035
-1
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@packaged-ui/ckeditor5-layout": "github:packaged-ui/ckeditor5-layout",
|
||||
"@visao/ckeditor5-flex-layout": "^29.1.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 -->
|
||||
<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/grid.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/flex.js}"></script>
|
||||
|
||||
<!-- Init Editor -->
|
||||
<script th:src="@{/js/manage/editor-config.js}"></script>
|
||||
|
||||
@@ -290,57 +290,178 @@
|
||||
<img th:src="${item['imageUrl']}" alt="Timeline Image" loading="lazy" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const syncTimelineHeights = () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
document.querySelectorAll('.sis-timeline__entry, .sis-timeline__img-slot').forEach(el => {
|
||||
el.style.minHeight = '';
|
||||
el.style.marginBottom = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Use the specific timeline section ID in case there are multiple
|
||||
const section = document.querySelector('#timeline-[[${stat != null ? stat.index : 0}]]');
|
||||
if (!section) return;
|
||||
const texts = section.querySelectorAll('.sis-timeline__entry');
|
||||
const imgs = section.querySelectorAll('.sis-timeline__img-slot');
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const syncTimelineHeights = () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
document.querySelectorAll('.sis-timeline__entry, .sis-timeline__img-slot').forEach(el => {
|
||||
el.style.minHeight = '';
|
||||
el.style.marginBottom = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Use the specific timeline section ID in case there are multiple
|
||||
const section = document.querySelector('#timeline-[[${stat != null ? stat.index : 0}]]');
|
||||
if (!section) return;
|
||||
const texts = section.querySelectorAll('.sis-timeline__entry');
|
||||
const imgs = section.querySelectorAll('.sis-timeline__img-slot');
|
||||
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
if (imgs[i]) {
|
||||
// reset inline styles first to measure natural height
|
||||
texts[i].style.minHeight = '';
|
||||
texts[i].style.marginBottom = '';
|
||||
imgs[i].style.minHeight = '';
|
||||
imgs[i].style.marginBottom = '';
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
if (imgs[i]) {
|
||||
// reset inline styles first to measure natural height
|
||||
texts[i].style.minHeight = '';
|
||||
texts[i].style.marginBottom = '';
|
||||
imgs[i].style.minHeight = '';
|
||||
imgs[i].style.marginBottom = '';
|
||||
|
||||
const textH = texts[i].getBoundingClientRect().height;
|
||||
const imgH = imgs[i].getBoundingClientRect().height;
|
||||
const maxH = Math.max(textH, imgH);
|
||||
const textH = texts[i].getBoundingClientRect().height;
|
||||
const imgH = imgs[i].getBoundingClientRect().height;
|
||||
const maxH = Math.max(textH, imgH);
|
||||
|
||||
texts[i].style.minHeight = maxH + 'px';
|
||||
imgs[i].style.minHeight = maxH + 'px';
|
||||
texts[i].style.minHeight = maxH + 'px';
|
||||
imgs[i].style.minHeight = maxH + 'px';
|
||||
|
||||
// sync bottom margin so they space out identically
|
||||
texts[i].style.marginBottom = '4rem';
|
||||
imgs[i].style.marginBottom = '4rem';
|
||||
}
|
||||
}
|
||||
};
|
||||
syncTimelineHeights();
|
||||
window.addEventListener('resize', syncTimelineHeights);
|
||||
// Run once more after images load
|
||||
window.addEventListener('load', syncTimelineHeights);
|
||||
// sync bottom margin so they space out identically
|
||||
texts[i].style.marginBottom = '4rem';
|
||||
imgs[i].style.marginBottom = '4rem';
|
||||
}
|
||||
}
|
||||
};
|
||||
syncTimelineHeights();
|
||||
window.addEventListener('resize', syncTimelineHeights);
|
||||
// Run once more after images load
|
||||
window.addEventListener('load', syncTimelineHeights);
|
||||
});
|
||||
</script>
|
||||
</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>
|
||||
<style>
|
||||
html {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user