feat: implement custom CSS generator, improve code editor support, and add media picker and HTML tag management tools

This commit is contained in:
2026-08-01 18:34:51 +07:00
parent 2a8c6bfc39
commit 1ee67e6a46
29 changed files with 1807 additions and 4083 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,80 @@
.sis-hover-card-grid {
padding-top: 30px;
padding-bottom: 30px;
}
.sis-hover-card-grid .row {
display: flex !important;
align-items: stretch !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"] {
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) !important;
cursor: pointer;
background-color: #ffffff !important;
border: 1px solid #e6f0fa !important;
border-radius: 12px !important;
box-shadow: 0 4px 15px rgba(0, 37, 84, 0.08) !important;
padding: 30px 20px !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: flex-start !important;
min-height: 260px;
text-align: center;
position: relative;
overflow: hidden;
}
.sis-hover-card-grid .row > [class*=\"col-\"] img {
height: 80px !important;
width: auto !important;
max-width: 100% !important;
object-fit: contain !important;
margin-bottom: 16px !important;
transition:
filter 0.4s ease,
transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"] h3 {
color: #0d71ba !important;
font-size: 1.25rem !important;
font-weight: 700 !important;
margin-bottom: 0 !important;
transition: color 0.4s ease !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"] p {
color: rgba(255, 255, 255, 0) !important;
font-size: 0.9rem !important;
line-height: 1.5 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
max-height: 0;
opacity: 0;
overflow: hidden;
transition:
max-height 0.4s ease,
opacity 0.4s ease,
color 0.4s ease,
margin-top 0.4s ease !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"]:hover,
.sis-hover-card-grid .row > [class*=\"col-\"].active {
background-color: #0d71ba !important;
border-color: #0d71ba !important;
transform: translateY(-8px) !important;
box-shadow: 0 12px 25px rgba(13, 113, 186, 0.3) !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"]:hover h3,
.sis-hover-card-grid .row > [class*=\"col-\"].active h3 {
color: #ffffff !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"]:hover p,
.sis-hover-card-grid .row > [class*=\"col-\"].active p {
color: #ffffff !important;
opacity: 1 !important;
max-height: 150px;
margin-top: 12px !important;
}
.sis-hover-card-grid .row > [class*=\"col-\"]:hover img,
.sis-hover-card-grid .row > [class*=\"col-\"].active img {
/* filter: brightness(0) invert(1) !important; */
transform: scale(1.05) !important;
}
@@ -0,0 +1,11 @@
document.addEventListener('DOMContentLoaded', function () {
var cards = document.querySelectorAll('.sis-hover-card-grid .row > [class*=\"col-\"]');
if (cards.length > 0) {
cards.forEach(function (card) {
card.addEventListener('click', function () {
cards.forEach(function (c) { c.classList.remove('active'); });
card.classList.add('active');
});
});
}
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
<section class="sis-hover-card-v2-section py-5">
<div class="container">
<h2 class="text-center display-4 text-primary-600 xl:mb-6 md:mb-4 mb-3 max-md:px-4 uppercase font-weight-bold">GIÁ TRỊ CỐT LÕI</h2>
</div>
</section>
@@ -565,6 +565,10 @@ figure.table table tr:hover {
text-decoration-line: none;
}
.text-center {
text-align: center;
}
/* ==========================================================================
5. Responsive Breakpoints — 3 Groups Only
========================================================================== */
@@ -1387,7 +1391,7 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */
}
.sis-timeline__img-slot-empty {
visibility: hidden;
visibility: hidden;
}
.sis-timeline__img-wrapper {
@@ -1455,3 +1459,270 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */
border-radius: 1rem;
}
}
/* ==========================================================================
Dedicated Hover Cards Plugin Styles
========================================================================== */
.sis-hover-card-section {
padding-top: 30px;
padding-bottom: 30px;
}
.sis-hover-card {
background-color: #ffffff;
border: 1px solid #e6f0fa;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 37, 84, 0.06);
padding: 25px 0px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
transition: all 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
position: relative;
overflow: hidden;
}
.sis-hover-card__icon {
margin-left: auto !important;
margin-right: auto !important;
margin-bottom: 18px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
height: 80px !important;
width: 80px !important;
position: relative !important;
flex-shrink: 0 !important;
}
.sis-hover-card__icon img {
height: 80px !important;
width: auto !important;
max-width: 100% !important;
object-fit: contain !important;
margin: 0 auto !important;
}
.sis-hover-card__img-main {
position: absolute !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
opacity: 1 !important;
display: block !important;
transition: all 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important;
}
.sis-hover-card__img-hover {
position: absolute !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
opacity: 0 !important;
pointer-events: none !important;
transition: all 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important;
}
.sis-hover-card__title {
color: var(--color-old-brick) !important;
font-size: 1.25rem !important;
font-weight: 700 !important;
margin-bottom: 0 !important;
transition: color 0.35s ease !important;
}
.sis-hover-card__desc {
color: rgba(255, 255, 255, 0) !important;
font-size: 0.9rem !important;
line-height: 1.55 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
max-height: 0;
opacity: 0;
overflow: hidden;
transition:
max-height 0.4s ease,
opacity 0.35s ease,
color 0.35s ease,
margin-top 0.35s ease !important;
}
.sis-hover-card:hover,
.sis-hover-card.active {
background-color: #0d71ba !important;
border-color: #0d71ba !important;
transform: translateY(-8px) !important;
box-shadow: 0 12px 28px rgba(13, 113, 186, 0.28) !important;
}
.sis-hover-card:hover .sis-hover-card__title,
.sis-hover-card.active .sis-hover-card__title {
color: #ffffff !important;
}
.sis-hover-card:hover .sis-hover-card__desc,
.sis-hover-card.active .sis-hover-card__desc {
color: #ffffff !important;
opacity: 1 !important;
max-height: 160px;
margin-top: 14px !important;
}
/* If hover image is present, swap images */
.sis-hover-card:hover .sis-hover-card__img-main:has(~ .sis-hover-card__img-hover),
.sis-hover-card.active .sis-hover-card__img-main:has(~ .sis-hover-card__img-hover) {
opacity: 0 !important;
}
.sis-hover-card:hover .sis-hover-card__img-hover,
.sis-hover-card.active .sis-hover-card__img-hover {
opacity: 1 !important;
transform: translate(-50%, -50%) scale(1.3) !important;
}
/* Fallback image filter if hover image is NOT present */
.sis-hover-card:hover .sis-hover-card__icon img:not(.sis-hover-card__img-hover):not(:has(~ .sis-hover-card__img-hover)),
.sis-hover-card.active .sis-hover-card__icon img:not(.sis-hover-card__img-hover):not(:has(~ .sis-hover-card__img-hover)) {
transform: translate(-50%, -50%) scale(1.3) !important;
}
/* ==========================================================================
Hover Cards Variant 2 (Hover 2 — Modern Blue & Glow Style)
========================================================================== */
.sis-hover-card-section .row,
.sis-hover-card-v2-section .row {
align-items: center !important;
}
.sis-hover-card-v2-section {
padding-top: 40px;
padding-bottom: 40px;
}
.sis-hover-card-v2 {
border: 1px solid rgba(53, 157, 253, 0.25) !important;
border-radius: 16px !important;
padding: 32px 20px !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
text-align: center !important;
cursor: pointer !important;
position: relative !important;
overflow: hidden !important;
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) !important;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18) !important;
}
.sis-hover-card-v2 .sis-hover-card__icon {
margin-bottom: 18px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
height: 85px !important;
width: 85px !important;
position: relative !important;
}
.sis-hover-card-v2 .sis-hover-card__icon img {
height: 80px !important;
width: auto !important;
max-width: 100% !important;
object-fit: contain !important;
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) !important;
}
.sis-hover-card-v2 .sis-hover-card__title {
-webkit-background-clip: text !important;
font-size: 1.3rem !important;
font-weight: 700 !important;
margin-bottom: 0 !important;
transition: all 0.35s ease !important;
}
.sis-hover-card-v2 .sis-hover-card__desc {
color: rgba(255, 255, 255, 0.9) !important;
font-size: 0.92rem !important;
line-height: 1.6 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
max-height: 0;
opacity: 0;
overflow: hidden;
transition:
max-height 0.4s ease,
opacity 0.35s ease,
margin-top 0.35s ease !important;
}
/* Hover 2 Active / Hover State */
.sis-hover-card-v2:hover,
.sis-hover-card-v2.active {
background: linear-gradient(135deg, #0d71ba 0%, #003c7e 100%) !important;
border-color: #359dfd !important;
transform: translateY(-8px) scale(1.02) !important;
box-shadow: 0 16px 36px rgba(13, 113, 186, 0.45) !important;
}
.sis-hover-card-v2:hover .sis-hover-card__title,
.sis-hover-card-v2.active .sis-hover-card__title {
-webkit-background-clip: unset !important;
-webkit-text-fill-color: #ffffff !important;
color: #ffffff !important;
}
.sis-hover-card-v2:hover .sis-hover-card__desc,
.sis-hover-card-v2.active .sis-hover-card__desc {
opacity: 1 !important;
max-height: 180px !important;
margin-top: 14px !important;
}
/* Hover 2 Image Swapping */
.sis-hover-card-v2:hover .sis-hover-card__img-main:has(~ .sis-hover-card__img-hover),
.sis-hover-card-v2.active .sis-hover-card__img-main:has(~ .sis-hover-card__img-hover) {
opacity: 0 !important;
}
.sis-hover-card-v2:hover .sis-hover-card__img-hover,
.sis-hover-card-v2.active .sis-hover-card__img-hover {
opacity: 1 !important;
transform: translate(-50%, -50%) scale(1.15) !important;
}
.sis-hover-card-v2:hover .sis-hover-card__icon img:not(.sis-hover-card__img-hover):not(:has(~ .sis-hover-card__img-hover)),
.sis-hover-card-v2.active .sis-hover-card__icon img:not(.sis-hover-card__img-hover):not(:has(~ .sis-hover-card__img-hover)) {
filter: brightness(0) invert(1) !important;
transform: translate(-50%, -50%) scale(1.15) !important;
}
/* Responsive Layout for Tablet & Mobile */
@media (max-width: 991px) {
.sis-hover-card-v2-section {
padding-top: 25px;
padding-bottom: 25px;
}
.sis-hover-card-v2 {
min-height: 220px;
padding: 24px 16px !important;
}
.sis-hover-card-v2 .sis-hover-card__title {
font-size: 1.15rem !important;
}
.sis-hover-card-v2 .sis-hover-card__desc {
font-size: 0.85rem !important;
}
}
@media (max-width: 575px) {
.sis-hover-card-v2-section {
padding-top: 15px;
padding-bottom: 15px;
}
.sis-hover-card-v2 {
min-height: 180px;
padding: 18px 10px !important;
}
.sis-hover-card-v2 .sis-hover-card__icon {
height: 65px !important;
width: 65px !important;
margin-bottom: 10px !important;
}
.sis-hover-card-v2 .sis-hover-card__icon img {
height: 60px !important;
}
.sis-hover-card-v2 .sis-hover-card__title {
font-size: 1.05rem !important;
}
}
@@ -31,6 +31,53 @@
// Global plugin registry — external plugins register here
window.SISEditorPlugins = window.SISEditorPlugins || {};
/**
* Global Media Library Modal Handler
* Callable from any Editor.js plugin via window.openSISMediaModal(callback)
*/
window.openSISMediaModal = function (callback) {
if (window.SISMediaPicker && typeof window.SISMediaPicker.open === 'function') {
window.SISMediaPicker.open(callback);
} else {
window.sisMediaSelectCallback = callback;
var modalEl = document.getElementById('sisMediaLibraryModal');
if (modalEl && typeof $ !== 'undefined' && $.fn && $.fn.modal) {
$(modalEl).modal('show');
}
}
};
/**
* Dynamic Monaco Editor Script Loader Helper
*/
window.loadMonacoEditor = function () {
if (window.monaco) return Promise.resolve(window.monaco);
if (window._monacoLoadingPromise) return window._monacoLoadingPromise;
window._monacoLoadingPromise = new Promise((resolve, reject) => {
if (typeof require !== 'undefined' && require.config) {
require(['vs/editor/editor.main'], function () {
resolve(window.monaco);
});
return;
}
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js';
script.onload = () => {
window.require.config({
paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' }
});
window.require(['vs/editor/editor.main'], () => {
resolve(window.monaco);
});
};
script.onerror = (err) => reject(err);
document.head.appendChild(script);
});
return window._monacoLoadingPromise;
};
class SISRawHtmlTool {
static get toolbox() {
return {
@@ -47,11 +94,13 @@ class SISRawHtmlTool {
stretched: !!(data && data.stretched)
};
this.textarea = null;
this.editorInstance = null;
}
render() {
const container = document.createElement('div');
container.style.width = '100%';
container.className = 'ce-raw-html-wrapper mb-3';
const stretchWrapper = document.createElement('div');
stretchWrapper.className = 'custom-control custom-switch mb-2';
@@ -72,32 +121,73 @@ class SISRawHtmlTool {
stretchWrapper.appendChild(this.stretchCheck);
stretchWrapper.appendChild(stretchLabel);
container.appendChild(stretchWrapper);
// Header status bar
const headerBar = document.createElement('div');
headerBar.className = 'bg-dark text-white p-2 rounded-top d-flex justify-content-between align-items-center small font-weight-bold';
headerBar.innerHTML = '<span><i class="fab fa-html5 text-danger"></i> Raw HTML Code (Monaco IDE Editor)</span><span class="badge badge-info font-weight-normal">HTML5</span>';
container.appendChild(headerBar);
// Monaco Container
const editorContainer = document.createElement('div');
editorContainer.className = 'monaco-editor-box border border-dark rounded-bottom';
editorContainer.style.height = '350px';
editorContainer.style.width = '100%';
container.appendChild(editorContainer);
// Textarea fallback (hidden when Monaco is active)
this.textarea = document.createElement('textarea');
this.textarea.className = 'form-control';
this.textarea.style.fontFamily = 'monospace';
this.textarea.style.fontSize = '13px';
this.textarea.style.minHeight = '250px';
this.textarea.style.whiteSpace = 'pre-wrap';
this.textarea.className = 'form-control d-none';
this.textarea.value = this.data.html || '';
container.appendChild(this.textarea);
if (this.readOnly) {
this.textarea.disabled = true;
this.stretchCheck.disabled = true;
this.textarea.disabled = true;
}
this.textarea.addEventListener('input', () => {
this.data.html = this.textarea.value;
// Initialize Monaco Editor
window.loadMonacoEditor().then((monaco) => {
if (!editorContainer || !document.body.contains(editorContainer)) return;
this.editorInstance = monaco.editor.create(editorContainer, {
value: this.data.html || '',
language: 'html',
theme: 'vs-dark',
automaticLayout: true,
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
tabSize: 2,
scrollBeyondLastLine: false,
readOnly: !!this.readOnly
});
this.editorInstance.onDidChangeModelContent(() => {
this.data.html = this.editorInstance.getValue();
if (this.textarea) this.textarea.value = this.data.html;
});
}).catch((err) => {
console.warn('[SIS Raw HTML Tool] Monaco Editor CDN fallback to Textarea:', err);
this.textarea.classList.remove('d-none');
this.textarea.style.minHeight = '300px';
this.textarea.style.backgroundColor = '#1e1e24';
this.textarea.style.color = '#66d9ef';
this.textarea.style.fontFamily = 'monospace';
editorContainer.style.display = 'none';
this.textarea.addEventListener('input', () => {
this.data.html = this.textarea.value;
});
});
container.appendChild(stretchWrapper);
container.appendChild(this.textarea);
return container;
}
save(container) {
save() {
const htmlVal = this.editorInstance ? this.editorInstance.getValue() : (this.textarea ? this.textarea.value : (this.data.html || ''));
return {
html: this.textarea ? this.textarea.value : this.data.html,
html: htmlVal,
stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)
};
}
@@ -19,7 +19,9 @@ class FlexTool {
direction: data.direction || 'row',
justify: data.justify || 'start',
align: data.align || 'stretch',
gap: data.gap || '3' // standard Bootstrap gap level
gap: data.gap || '3', // standard Bootstrap gap level
globalClass: data.globalClass || '',
globalId: data.globalId || ''
};
this.activeInstances = {};
@@ -186,6 +188,7 @@ class FlexTool {
classInput.value = this.data.globalClass || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
this.globalClassInput = classInput;
classDiv.appendChild(classInput);
bgSettingsRow.appendChild(classDiv);
@@ -200,6 +203,7 @@ class FlexTool {
idInput.value = this.data.globalId || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
this.globalIdInput = idInput;
idDiv.appendChild(idInput);
bgSettingsRow.appendChild(idDiv);
@@ -710,8 +714,8 @@ class FlexTool {
justify: this.data.justify,
align: this.data.align,
gap: this.data.gap,
globalClass: this.data.globalClass || '',
globalId: this.data.globalId || ''
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || ''),
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || '')
};
for (let i = 1; i <= this.data.cols; i++) {
const type = this.data[`type${i}`] || 'html';
@@ -15,7 +15,9 @@ class GridTool {
this.api = api;
this.readOnly = readOnly;
this.data = {
cols: parseInt(data.cols) || 2
cols: parseInt(data.cols) || 2,
globalClass: data.globalClass || '',
globalId: data.globalId || ''
};
this.activeInstances = {};
@@ -80,6 +82,7 @@ class GridTool {
classInput.value = this.data.globalClass || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
this.globalClassInput = classInput;
classDiv.appendChild(classInput);
settingsRow.appendChild(classDiv);
@@ -94,6 +97,7 @@ class GridTool {
idInput.value = this.data.globalId || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
this.globalIdInput = idInput;
idDiv.appendChild(idInput);
settingsRow.appendChild(idDiv);
@@ -602,8 +606,8 @@ class GridTool {
save(blockContent) {
const savedData = {
cols: this.data.cols,
globalClass: this.data.globalClass || '',
globalId: this.data.globalId || ''
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || ''),
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || '')
};
for (let i = 1; i <= this.data.cols; i++) {
const type = this.data[`type${i}`] || 'html';
@@ -0,0 +1,428 @@
/**
* SISHoverCardTool — Dedicated Hover Cards Block Tool for Editor.js
* Allows content managers to freely add, remove, reorder, and configure interactive hover cards.
*/
class SISHoverCardTool {
static get toolbox() {
return {
title: 'Hover Cards',
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="7" rx="1"></rect><rect x="14" y="3" width="7" height="7" rx="1"></rect><rect x="14" y="14" width="7" height="7" rx="1"></rect><rect x="3" y="14" width="7" height="7" rx="1"></rect></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.instanceId = 'hc_' + Math.random().toString(36).substring(7);
this.data = {
cols: parseInt(data && data.cols) || 4,
styleVariant: (data && data.styleVariant) || 'v1',
globalClass: (data && data.globalClass) || 'sis-hover-card-section',
globalId: (data && data.globalId) || '',
items: (data && data.items && Array.isArray(data.items)) ? data.items : []
};
// Add initial default item if list is empty
if (this.data.items.length === 0) {
this.data.items.push({
title: 'Card Title',
description: 'Card description text...',
imageUrl: '',
active: true
});
}
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-hovercard-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
const titleLabel = document.createElement('div');
titleLabel.className = 'font-weight-bold text-primary small mb-3';
titleLabel.innerHTML = '<i class="fas fa-th-large"></i> Dedicated Hover Cards Settings';
this.wrapper.appendChild(titleLabel);
// Global Settings Row
const settingsRow = document.createElement('div');
settingsRow.className = 'form-row mb-3 pb-2 border-bottom';
// 1. Cols selector
const colDiv = document.createElement('div');
colDiv.className = 'col-md-3 mb-2';
colDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Columns per Row</label>';
const colSelect = document.createElement('select');
colSelect.className = 'form-control form-control-sm';
[1, 2, 3, 4, 6].forEach(num => {
const opt = document.createElement('option');
opt.value = num;
opt.textContent = `${num} Column${num > 1 ? 's' : ''}`;
if (num === this.data.cols) opt.selected = true;
colSelect.appendChild(opt);
});
if (this.readOnly) colSelect.disabled = true;
colSelect.addEventListener('change', (e) => {
this.data.cols = parseInt(e.target.value) || 4;
});
this.colsSelect = colSelect;
colDiv.appendChild(colSelect);
settingsRow.appendChild(colDiv);
// 2. Style Variant Selector (Hover 1 vs Hover 2)
const variantDiv = document.createElement('div');
variantDiv.className = 'col-md-3 mb-2';
variantDiv.innerHTML = '<label class="small font-weight-bold text-primary mb-1"><i class="fas fa-palette"></i> Card Style Variant</label>';
const variantSelect = document.createElement('select');
variantSelect.className = 'form-control form-control-sm';
const optV1 = document.createElement('option');
optV1.value = 'v1';
optV1.textContent = 'Hover 1 (Standard Light)';
if (this.data.styleVariant === 'v1') optV1.selected = true;
variantSelect.appendChild(optV1);
const optV2 = document.createElement('option');
optV2.value = 'v2';
optV2.textContent = 'Hover 2 (Modern Blue & Glow)';
if (this.data.styleVariant === 'v2') optV2.selected = true;
variantSelect.appendChild(optV2);
if (this.readOnly) variantSelect.disabled = true;
variantSelect.addEventListener('change', (e) => {
this.data.styleVariant = e.target.value;
});
this.variantSelect = variantSelect;
variantDiv.appendChild(variantSelect);
settingsRow.appendChild(variantDiv);
// 2. Global CSS Class
const classDiv = document.createElement('div');
classDiv.className = 'col-md-5 mb-2';
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Global CSS Class(es)</label>';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.placeholder = 'e.g. sis-hover-card-section';
classInput.value = this.data.globalClass || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
this.globalClassInput = classInput;
classDiv.appendChild(classInput);
settingsRow.appendChild(classDiv);
// 3. Global HTML ID
const idDiv = document.createElement('div');
idDiv.className = 'col-md-4 mb-2';
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Global HTML ID</label>';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.placeholder = 'e.g. hover-cards-section';
idInput.value = this.data.globalId || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
this.globalIdInput = idInput;
idDiv.appendChild(idInput);
settingsRow.appendChild(idDiv);
this.wrapper.appendChild(settingsRow);
// Cards items container
this.itemsContainer = document.createElement('div');
this.wrapper.appendChild(this.itemsContainer);
this._renderAllItems();
if (!this.readOnly) {
const addButton = document.createElement('button');
addButton.type = 'button';
addButton.className = 'btn btn-sm btn-success mt-2';
addButton.innerHTML = '<i class="fas fa-plus"></i> Add Hover Card';
addButton.addEventListener('click', () => {
this.data.items.push({
title: 'New Card Title',
description: 'New card description text...',
imageUrl: '',
active: false
});
this._renderAllItems();
});
this.wrapper.appendChild(addButton);
}
return this.wrapper;
}
_renderAllItems() {
this.itemsContainer.innerHTML = '';
this.data.items.forEach((item, index) => {
this._renderItem(item, index);
});
}
_renderItem(item, index) {
const itemBox = document.createElement('div');
itemBox.className = 'mb-3 p-3 bg-white border rounded shadow-sm';
itemBox.style.position = 'relative';
// Top Control Action buttons (Move Up, Move Down, Delete)
if (!this.readOnly) {
const actionGroup = document.createElement('div');
actionGroup.style.position = 'absolute';
actionGroup.style.top = '10px';
actionGroup.style.right = '10px';
actionGroup.className = 'btn-group btn-group-sm';
if (index > 0) {
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.className = 'btn btn-outline-secondary';
upBtn.innerHTML = '<i class="fas fa-arrow-up"></i>';
upBtn.title = 'Move Up';
upBtn.addEventListener('click', () => {
const temp = this.data.items[index];
this.data.items[index] = this.data.items[index - 1];
this.data.items[index - 1] = temp;
this._renderAllItems();
});
actionGroup.appendChild(upBtn);
}
if (index < this.data.items.length - 1) {
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.className = 'btn btn-outline-secondary';
downBtn.innerHTML = '<i class="fas fa-arrow-down"></i>';
downBtn.title = 'Move Down';
downBtn.addEventListener('click', () => {
const temp = this.data.items[index];
this.data.items[index] = this.data.items[index + 1];
this.data.items[index + 1] = temp;
this._renderAllItems();
});
actionGroup.appendChild(downBtn);
}
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-danger';
removeBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeBtn.title = 'Remove Card';
removeBtn.addEventListener('click', () => {
this.data.items.splice(index, 1);
this._renderAllItems();
});
actionGroup.appendChild(removeBtn);
itemBox.appendChild(actionGroup);
}
// Header Label
const itemHeader = document.createElement('h6');
itemHeader.className = 'font-weight-bold text-dark mb-3';
itemHeader.innerHTML = `<i class="fas fa-id-card text-info mr-1"></i> Card #${index + 1}`;
itemBox.appendChild(itemHeader);
// 1. Card Title
const titleGroup = document.createElement('div');
titleGroup.className = 'form-group mb-2';
titleGroup.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Card Title</label>';
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.className = 'form-control form-control-sm';
titleInput.placeholder = 'e.g. Tiên phong';
titleInput.value = item.title || '';
if (this.readOnly) titleInput.disabled = true;
titleInput.addEventListener('input', (e) => { item.title = e.target.value; });
titleGroup.appendChild(titleInput);
itemBox.appendChild(titleGroup);
// 2. Card Description
const descGroup = document.createElement('div');
descGroup.className = 'form-group mb-2';
descGroup.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Card Description</label>';
const descInput = document.createElement('textarea');
descInput.className = 'form-control form-control-sm';
descInput.rows = 2;
descInput.placeholder = 'e.g. Phát triển và ứng dụng kỹ thuật tiên tiến...';
descInput.value = item.description || '';
if (this.readOnly) descInput.disabled = true;
descInput.addEventListener('input', (e) => { item.description = e.target.value; });
descGroup.appendChild(descInput);
itemBox.appendChild(descGroup);
// 3. Image URL + Media Library Selector
const imgGroup = document.createElement('div');
imgGroup.className = 'form-group mb-2';
imgGroup.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Card Icon / Image URL</label>';
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group input-group-sm mb-2';
const imgInput = document.createElement('input');
imgInput.type = 'text';
imgInput.className = 'form-control';
imgInput.placeholder = '/uploads/2026/07/icon.jpg';
imgInput.value = item.imageUrl || '';
if (this.readOnly) imgInput.disabled = true;
const previewContainer = document.createElement('div');
previewContainer.className = 'mt-1';
const updatePreview = (url) => {
previewContainer.innerHTML = '';
if (url) {
const img = document.createElement('img');
img.src = url;
img.style.maxHeight = '60px';
img.style.maxWidth = '100px';
img.style.objectFit = 'contain';
img.className = 'border rounded p-1 bg-light';
previewContainer.appendChild(img);
}
};
updatePreview(item.imageUrl);
imgInput.addEventListener('input', (e) => {
item.imageUrl = e.target.value.trim();
updatePreview(item.imageUrl);
});
inputGroup.appendChild(imgInput);
if (!this.readOnly) {
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const mediaBtn = document.createElement('button');
mediaBtn.type = 'button';
mediaBtn.className = 'btn btn-outline-info';
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
mediaBtn.addEventListener('click', () => {
if (typeof window.openSISMediaModal === 'function') {
window.openSISMediaModal((selectedUrl) => {
imgInput.value = selectedUrl;
item.imageUrl = selectedUrl;
updatePreview(selectedUrl);
});
} else {
alert('Media modal function openSISMediaModal is not available.');
}
});
appendDiv.appendChild(mediaBtn);
inputGroup.appendChild(appendDiv);
}
imgGroup.appendChild(inputGroup);
imgGroup.appendChild(previewContainer);
itemBox.appendChild(imgGroup);
// 4. Hover Icon / Image URL (Swapped on hover)
const hoverImgGroup = document.createElement('div');
hoverImgGroup.className = 'form-group mb-2';
hoverImgGroup.innerHTML = '<label class="small font-weight-bold text-info mb-1"><i class="fas fa-sync-alt"></i> Hover Icon / Image URL (Optional - Swaps on Hover)</label>';
const hoverInputGroup = document.createElement('div');
hoverInputGroup.className = 'input-group input-group-sm mb-2';
const hoverImgInput = document.createElement('input');
hoverImgInput.type = 'text';
hoverImgInput.className = 'form-control';
hoverImgInput.placeholder = '/uploads/2026/07/hover-icon.jpg (optional)';
hoverImgInput.value = item.hoverImageUrl || '';
if (this.readOnly) hoverImgInput.disabled = true;
const hoverPreviewContainer = document.createElement('div');
hoverPreviewContainer.className = 'mt-1';
const updateHoverPreview = (url) => {
hoverPreviewContainer.innerHTML = '';
if (url) {
const img = document.createElement('img');
img.src = url;
img.style.maxHeight = '60px';
img.style.maxWidth = '100px';
img.style.objectFit = 'contain';
img.className = 'border rounded p-1 bg-info text-white';
hoverPreviewContainer.appendChild(img);
}
};
updateHoverPreview(item.hoverImageUrl);
hoverImgInput.addEventListener('input', (e) => {
item.hoverImageUrl = e.target.value.trim();
updateHoverPreview(item.hoverImageUrl);
});
hoverInputGroup.appendChild(hoverImgInput);
if (!this.readOnly) {
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const mediaBtn = document.createElement('button');
mediaBtn.type = 'button';
mediaBtn.className = 'btn btn-outline-info';
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
mediaBtn.addEventListener('click', () => {
if (typeof window.openSISMediaModal === 'function') {
window.openSISMediaModal((selectedUrl) => {
hoverImgInput.value = selectedUrl;
item.hoverImageUrl = selectedUrl;
updateHoverPreview(selectedUrl);
});
} else {
alert('Media modal function openSISMediaModal is not available.');
}
});
appendDiv.appendChild(mediaBtn);
hoverInputGroup.appendChild(appendDiv);
}
hoverImgGroup.appendChild(hoverInputGroup);
hoverImgGroup.appendChild(hoverPreviewContainer);
itemBox.appendChild(hoverImgGroup);
// Default Active Radio (Only 1 option active at a time)
const activeRadioDiv = document.createElement('div');
activeRadioDiv.className = 'custom-control custom-radio mt-2';
const activeRadio = document.createElement('input');
activeRadio.type = 'radio';
activeRadio.name = 'hovercard_active_' + this.instanceId;
activeRadio.className = 'custom-control-input';
const radioId = 'hovercard_active_' + index + '_' + Math.random().toString(36).substring(7);
activeRadio.id = radioId;
activeRadio.checked = !!item.active;
if (this.readOnly) activeRadio.disabled = true;
activeRadio.addEventListener('change', () => {
this.data.items.forEach((it, i) => {
it.active = (i === index);
});
});
const activeLabel = document.createElement('label');
activeLabel.className = 'custom-control-label small font-weight-bold text-secondary';
activeLabel.htmlFor = radioId;
activeLabel.innerHTML = 'Default Active Card (Only 1 active at a time)';
activeRadioDiv.appendChild(activeRadio);
activeRadioDiv.appendChild(activeLabel);
itemBox.appendChild(activeRadioDiv);
this.itemsContainer.appendChild(itemBox);
}
save() {
return {
cols: this.colsSelect ? parseInt(this.colsSelect.value) : (this.data.cols || 4),
styleVariant: this.variantSelect ? this.variantSelect.value : (this.data.styleVariant || 'v1'),
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || 'sis-hover-card-section'),
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
items: this.data.items || []
};
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['hovercard'] = { class: SISHoverCardTool };
@@ -0,0 +1,201 @@
/**
* SISTagTool — Custom HTML Tag Opening, Closing & Wrapper Block Tool for Editor.js
* Allows content managers to define custom HTML tags (div, section, article, header, footer, etc.)
* with custom Class(es), ID, and Attributes (data-*, style, aria-*, etc.).
*/
class SISTagTool {
static get toolbox() {
return {
title: 'HTML Tag (Open/Close)',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 18l6-6-6-6M8 6l-6 6 6 6"></path></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
mode: data && data.mode ? data.mode : 'open', // 'open' or 'close'
tagName: data && data.tagName ? data.tagName : 'div',
elementId: data && data.elementId ? data.elementId : '',
elementClass: data && data.elementClass ? data.elementClass : '',
attributes: data && data.attributes ? data.attributes : ''
};
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-tag-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
this.wrapper.style.borderLeft = '4px solid #007bff';
const headerDiv = document.createElement('div');
headerDiv.className = 'font-weight-bold text-primary small mb-3 d-flex justify-content-between align-items-center';
headerDiv.innerHTML = '<span><i class="fas fa-code"></i> HTML Custom Tag Settings</span><span class="badge badge-primary tag-preview-badge" style="font-family: monospace;">&lt;div&gt;</span>';
this.wrapper.appendChild(headerDiv);
const formRow = document.createElement('div');
formRow.className = 'form-row';
// 1. Tag Mode (Opening Tag / Closing Tag)
const modeDiv = document.createElement('div');
modeDiv.className = 'col-md-3 mb-2';
modeDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Tag Action / Mode</label>';
const modeSelect = document.createElement('select');
modeSelect.className = 'form-control form-control-sm';
const optOpen = document.createElement('option');
optOpen.value = 'open';
optOpen.textContent = 'Opening Tag (<tag>)';
if (this.data.mode === 'open') optOpen.selected = true;
modeSelect.appendChild(optOpen);
const optClose = document.createElement('option');
optClose.value = 'close';
optClose.textContent = 'Closing Tag (</tag>)';
if (this.data.mode === 'close') optClose.selected = true;
modeSelect.appendChild(optClose);
if (this.readOnly) modeSelect.disabled = true;
this.modeSelect = modeSelect;
modeDiv.appendChild(modeSelect);
formRow.appendChild(modeDiv);
// 2. Tag Name (div, section, article, header, footer, etc.)
const nameDiv = document.createElement('div');
nameDiv.className = 'col-md-3 mb-2';
nameDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Tag Name</label>';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'form-control form-control-sm';
nameInput.placeholder = 'e.g. div, section, article';
nameInput.value = this.data.tagName || 'div';
if (this.readOnly) nameInput.disabled = true;
this.nameInput = nameInput;
nameDiv.appendChild(nameInput);
formRow.appendChild(nameDiv);
// 3. Element ID
const idDiv = document.createElement('div');
idDiv.className = 'col-md-3 mb-2';
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">ID (id="..." )</label>';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.placeholder = 'e.g. heroSection';
idInput.value = this.data.elementId || '';
if (this.readOnly) idInput.disabled = true;
this.idInput = idInput;
idDiv.appendChild(idInput);
formRow.appendChild(idDiv);
// 4. Element CSS Class(es)
const classDiv = document.createElement('div');
classDiv.className = 'col-md-3 mb-2';
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Class(es) (class="..." )</label>';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.placeholder = 'e.g. container py-5 my-section';
classInput.value = this.data.elementClass || '';
if (this.readOnly) classInput.disabled = true;
this.classInput = classInput;
classDiv.appendChild(classInput);
formRow.appendChild(classDiv);
// 5. Custom Attributes Row
const attrRow = document.createElement('div');
attrRow.className = 'form-row mt-2 attr-row';
const attrDiv = document.createElement('div');
attrDiv.className = 'col-12';
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Custom Attributes (e.g. data-aos="fade-up" style="background:#fff")</label>';
const attrInput = document.createElement('input');
attrInput.type = 'text';
attrInput.className = 'form-control form-control-sm';
attrInput.placeholder = 'e.g. data-aos="fade-up" style="background: #f8f9fa;" aria-label="Section"';
attrInput.value = this.data.attributes || '';
if (this.readOnly) attrInput.disabled = true;
this.attrInput = attrInput;
attrDiv.appendChild(attrInput);
attrRow.appendChild(attrDiv);
// Live HTML Code Preview Box
const previewBox = document.createElement('div');
previewBox.className = 'mt-3 p-2 bg-dark text-success rounded small font-weight-bold text-monospace live-tag-preview';
previewBox.style.fontSize = '12px';
const updatePreview = () => {
const mode = this.modeSelect.value;
const tag = (this.nameInput.value.trim() || 'div').toLowerCase();
const idVal = this.idInput.value.trim();
const classVal = this.classInput.value.trim();
const attrVal = this.attrInput.value.trim();
if (mode === 'close') {
previewBox.textContent = `</${tag}>`;
attrRow.style.display = 'none';
idDiv.style.display = 'none';
classDiv.style.display = 'none';
} else {
attrRow.style.display = 'flex';
idDiv.style.display = 'block';
classDiv.style.display = 'block';
let tagStr = `<${tag}`;
if (idVal) tagStr += ` id="${idVal}"`;
if (classVal) tagStr += ` class="${classVal}"`;
if (attrVal) tagStr += ` ${attrVal}`;
tagStr += `>`;
previewBox.textContent = tagStr;
}
const previewBadge = this.wrapper.querySelector('.tag-preview-badge');
if (previewBadge) previewBadge.textContent = previewBox.textContent;
};
this.modeSelect.addEventListener('change', updatePreview);
this.nameInput.addEventListener('input', updatePreview);
this.idInput.addEventListener('input', updatePreview);
this.classInput.addEventListener('input', updatePreview);
this.attrInput.addEventListener('input', updatePreview);
this.wrapper.appendChild(formRow);
this.wrapper.appendChild(attrRow);
this.wrapper.appendChild(previewBox);
updatePreview();
return this.wrapper;
}
save() {
const mode = this.modeSelect ? this.modeSelect.value : (this.data.mode || 'open');
const tagName = this.nameInput ? (this.nameInput.value.trim().toLowerCase() || 'div') : (this.data.tagName || 'div');
const elementId = this.idInput ? this.idInput.value.trim() : (this.data.elementId || '');
const elementClass = this.classInput ? this.classInput.value.trim() : (this.data.elementClass || '');
const attributes = this.attrInput ? this.attrInput.value.trim() : (this.data.attributes || '');
let rawHtmlTag = '';
if (mode === 'close') {
rawHtmlTag = `</${tagName}>`;
} else {
rawHtmlTag = `<${tagName}`;
if (elementId) rawHtmlTag += ` id="${elementId}"`;
if (elementClass) rawHtmlTag += ` class="${elementClass}"`;
if (attributes) rawHtmlTag += ` ${attributes}`;
rawHtmlTag += `>`;
}
return {
mode: mode,
tagName: tagName,
elementId: elementId,
elementClass: elementClass,
attributes: attributes,
rawHtmlTag: rawHtmlTag
};
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['tag'] = { class: SISTagTool };
@@ -0,0 +1,208 @@
/**
* SIS Media Picker & Library Modal Utility
* Standalone reusable module to select and upload media files anywhere in the application.
*
* Usage:
* window.openSISMediaModal(function(selectedUrl, selectedMediaObj) {
* console.log("Selected Image URL:", selectedUrl);
* });
*
* Or via object API:
* SISMediaPicker.open(function(selectedUrl, selectedMediaObj) { ... });
*/
(function (window, document) {
'use strict';
var SISMediaPicker = {
callback: null,
mediaData: [],
modalId: 'sisMediaLibraryModal',
initModal: function () {
var modalEl = document.getElementById(this.modalId);
if (modalEl) return modalEl;
modalEl = document.createElement('div');
modalEl.id = this.modalId;
modalEl.className = 'modal fade';
modalEl.setAttribute('tabindex', '-1');
modalEl.setAttribute('role', 'dialog');
modalEl.setAttribute('aria-hidden', 'true');
modalEl.innerHTML =
'<div class="modal-dialog modal-xl" role="document">' +
'<div class="modal-content border-0 shadow-lg">' +
'<div class="modal-header bg-primary text-white p-3">' +
'<h5 class="modal-title font-weight-bold" id="' + this.modalId + 'Label">' +
'<i class="fas fa-images mr-2"></i> Thư viện Media / Chọn Hình ảnh' +
'</h5>' +
'<button type="button" class="close text-white opacity-100" data-dismiss="modal" aria-label="Close">' +
'<span aria-hidden="true">&times;</span>' +
'</button>' +
'</div>' +
'<div class="modal-body bg-light p-3">' +
'<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 bg-white p-2 border rounded shadow-sm">' +
'<div class="input-group input-group-sm mb-2 mb-md-0" style="max-width: 380px;">' +
'<div class="input-group-prepend"><span class="input-group-text bg-light"><i class="fas fa-search text-muted"></i></span></div>' +
'<input type="text" class="form-control" id="sisMediaSearchInput" placeholder="Tìm kiếm theo tên file..." />' +
'</div>' +
'<div>' +
'<label class="btn btn-sm btn-success mb-0 cursor-pointer shadow-sm" style="cursor: pointer;">' +
'<i class="fas fa-cloud-upload-alt mr-1"></i> Tải ảnh mới lên' +
'<input type="file" id="sisMediaUploadInput" accept="image/*" style="display: none;" />' +
'</label>' +
'</div>' +
'</div>' +
'<div id="sisMediaGrid" class="row" style="max-height: 480px; overflow-y: auto; align-content: start;">' +
'<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải danh sách hình ảnh...</span></div>' +
'</div>' +
'</div>' +
'<div class="modal-footer bg-white p-2">' +
'<button type="button" class="btn btn-sm btn-secondary px-3" data-dismiss="modal">Đóng</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(modalEl);
// Bind Search Input Handler
var self = this;
document.addEventListener('input', function (e) {
if (e.target && e.target.id === 'sisMediaSearchInput') {
var term = e.target.value.toLowerCase().trim();
var filtered = self.mediaData.filter(function (item) {
var name = (item.originalFilename || item.name || '').toLowerCase();
return name.includes(term);
});
self.renderGrid(filtered);
}
});
// Bind Upload Input Handler
document.addEventListener('change', function (e) {
if (e.target && e.target.id === 'sisMediaUploadInput') {
var file = e.target.files[0];
if (!file) return;
var formData = new FormData();
formData.append('file', file);
var grid = document.getElementById('sisMediaGrid');
if (grid) {
grid.innerHTML = '<div class="col-12 text-center text-info py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải tệp lên server...</span></div>';
}
fetch('/api/manage/media/upload', {
method: 'POST',
body: formData
})
.then(function (res) { return res.json(); })
.then(function (data) {
if (data && data.success === 1 && data.file && data.file.url) {
self.selectItem(data.file.url, data.file);
} else {
alert('Tải tệp lên thất bại. Vui lòng thử lại!');
self.fetchMediaList();
}
})
.catch(function (err) {
alert('Lỗi tải tệp: ' + err.message);
self.fetchMediaList();
});
}
});
return modalEl;
},
open: function (callback) {
this.callback = callback;
var modalEl = this.initModal();
if (typeof $ !== 'undefined' && $.fn && $.fn.modal) {
$(modalEl).modal('show');
} else {
modalEl.style.display = 'block';
modalEl.classList.add('show');
}
this.fetchMediaList();
},
fetchMediaList: function () {
var grid = document.getElementById('sisMediaGrid');
if (grid) {
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x text-primary"></i><br/><span class="mt-2 d-block small">Đang nạp thư viện...</span></div>';
}
var self = this;
fetch('/api/manage/media/list')
.then(function (res) { return res.json(); })
.then(function (data) {
self.mediaData = data || [];
self.renderGrid(self.mediaData);
})
.catch(function (err) {
if (grid) {
grid.innerHTML = '<div class="col-12 text-center text-danger py-4"><i class="fas fa-exclamation-triangle fa-2x mb-2"></i><br/>Không thể tải danh sách tệp. Bạn có thể dán đường dẫn URL trực tiếp.</div>';
}
});
},
renderGrid: function (list) {
var grid = document.getElementById('sisMediaGrid');
if (!grid) return;
if (!list || list.length === 0) {
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="far fa-folder-open fa-2x mb-2"></i><br/>Chưa có tệp hình ảnh nào. Bấm nút "Tải ảnh mới lên" để thêm.</div>';
return;
}
var self = this;
grid.innerHTML = list.map(function (item) {
var url = item.fileUrl || item.url || '';
var name = item.originalFilename || item.name || 'Image';
return '<div class="col-lg-2 col-md-3 col-sm-4 col-6 mb-3">' +
'<div class="card h-100 border shadow-sm media-select-card" style="cursor: pointer; transition: all 0.2s;" data-url="' + url + '">' +
'<div style="height: 110px; background: #f8f9fc; display: flex; align-items: center; justify-content: center; overflow: hidden;" class="p-1">' +
'<img src="' + url + '" style="max-height: 100%; max-width: 100%; object-fit: contain;" alt="' + name + '" />' +
'</div>' +
'<div class="card-body p-2 text-center bg-white border-top">' +
'<p class="card-text small text-truncate m-0 font-weight-bold text-dark" title="' + name + '">' + name + '</p>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
// Add click listeners to items
grid.querySelectorAll('.media-select-card').forEach(function (card) {
card.addEventListener('click', function () {
var selectedUrl = card.getAttribute('data-url');
self.selectItem(selectedUrl);
});
});
},
selectItem: function (url, mediaObj) {
if (typeof this.callback === 'function') {
this.callback(url, mediaObj);
}
var modalEl = document.getElementById(this.modalId);
if (modalEl) {
if (typeof $ !== 'undefined' && $.fn && $.fn.modal) {
$(modalEl).modal('hide');
} else {
modalEl.style.display = 'none';
modalEl.classList.remove('show');
}
}
}
};
// Expose standalone module globally
window.SISMediaPicker = SISMediaPicker;
// Backward-compatible global helper function
window.openSISMediaModal = function (callback) {
SISMediaPicker.open(callback);
};
})(window, document);
@@ -225,7 +225,12 @@
<label for="customCss" class="font-weight-bold text-primary mb-0">
<i class="fab fa-css3-alt"></i> Custom CSS (Page-Specific Styles)
</label>
<span class="badge badge-info px-2 py-1" style="font-family: monospace;">CSS3</span>
<div>
<button type="button" class="btn btn-outline-primary btn-sm px-2 py-0 mr-1" id="btnGenMediaQueryPage" title="Auto-generate Media Queries for Mobile, Tablet & Desktop" style="font-size: 11px;">
<i class="fas fa-mobile-alt"></i> Auto-Generate Media Queries
</button>
<span class="badge badge-info px-2 py-1" style="font-family: monospace;">CSS3</span>
</div>
</div>
<textarea
class="form-control text-white"
@@ -434,6 +439,11 @@
<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>
<script th:src="@{/js/manage/editor-plugins/hover-card.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tag.js}"></script>
<!-- SIS Standalone Media Picker Library -->
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
<!-- Init Editor -->
<script th:src="@{/js/manage/editor-config.js}"></script>
@@ -518,6 +528,12 @@
exportBtn.addEventListener('click', function () {
if (window.sisEditor && typeof window.sisEditor.save === 'function') {
window.sisEditor.save().then(function (outputData) {
// Attach dedicated CSS and JS if they exist
var cssElement = document.getElementById('customCss');
var jsElement = document.getElementById('customJs');
if (cssElement) outputData.customCss = cssElement.value;
if (jsElement) outputData.customJs = jsElement.value;
var jsonString = JSON.stringify(outputData, null, 2);
var blob = new Blob([jsonString], { type: 'application/json' });
var url = URL.createObjectURL(blob);
@@ -594,6 +610,16 @@
throw new Error('Invalid Editor.js format: Missing "blocks" array.');
}
// If dedicated CSS and JS exist in the backup, load them into textareas
if (parsed.hasOwnProperty('customCss')) {
var cssElement = document.getElementById('customCss');
if (cssElement) cssElement.value = parsed.customCss;
}
if (parsed.hasOwnProperty('customJs')) {
var jsElement = document.getElementById('customJs');
if (jsElement) jsElement.value = parsed.customJs;
}
if (window.sisEditor && typeof window.sisEditor.render === 'function') {
window.sisEditor.render(parsed).then(function () {
importModal.modal('hide');
@@ -610,6 +636,68 @@
}
});
}
// --- Dedicated Custom CSS Auto Generator (Desktop, Tablet & Mobile Media Queries) ---
function insertResponsiveMediaQueries(textarea) {
if (!textarea) return;
var template = `/* ==========================================================================
DEFAULT / DESKTOP STYLES (≥ 992px)
========================================================================== */
.custom-section {
padding: 40px 0;
font-size: 16px;
}
/* ==========================================================================
TABLET RESPONSIVE STYLES (768px 991px)
========================================================================== */
@media (max-width: 991px) {
.custom-section {
padding: 25px 0;
font-size: 15px;
}
}
/* ==========================================================================
MOBILE PHONE RESPONSIVE STYLES (≤ 767px)
========================================================================== */
@media (max-width: 767px) {
.custom-section {
padding: 15px 0;
font-size: 14px;
}
}
`;
if (textarea.value.trim() === '') {
textarea.value = template;
} else {
textarea.value += '\n\n' + template;
}
textarea.focus();
}
var btnGenCss = document.getElementById('btnGenMediaQueryPage');
if (btnGenCss) {
btnGenCss.addEventListener('click', function () {
insertResponsiveMediaQueries(document.getElementById('customCss'));
});
}
// Tab indent support for Code Textareas
function enableTabIndentation(textarea) {
if (!textarea) return;
textarea.addEventListener('keydown', function (e) {
if (e.key === 'Tab') {
e.preventDefault();
var start = this.selectionStart;
var end = this.selectionEnd;
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 2;
}
});
}
enableTabIndentation(document.getElementById('customCss'));
enableTabIndentation(document.getElementById('customJs'));
});
</script>
</section>
@@ -213,7 +213,12 @@
<label for="postCustomCss" class="font-weight-bold text-primary mb-0">
<i class="fab fa-css3-alt"></i> Custom CSS (Post-Specific Styles)
</label>
<span class="badge badge-info px-2 py-1" style="font-family: monospace;">CSS3</span>
<div>
<button type="button" class="btn btn-outline-primary btn-sm px-2 py-0 mr-1" id="btnGenMediaQueryPost" title="Auto-generate Media Queries for Mobile, Tablet & Desktop" style="font-size: 11px;">
<i class="fas fa-mobile-alt"></i> Auto-Generate Media Queries
</button>
<span class="badge badge-info px-2 py-1" style="font-family: monospace;">CSS3</span>
</div>
</div>
<textarea
class="form-control text-white"
@@ -746,6 +751,68 @@
}
layoutSelect.addEventListener('change', toggleEventTime);
toggleEventTime(); // Initial check
// --- Dedicated Custom CSS Auto Generator (Desktop, Tablet & Mobile Media Queries) ---
function insertResponsiveMediaQueries(textarea) {
if (!textarea) return;
var template = `/* ==========================================================================
DEFAULT / DESKTOP STYLES (≥ 992px)
========================================================================== */
.custom-post-block {
padding: 40px 0;
font-size: 16px;
}
/* ==========================================================================
TABLET RESPONSIVE STYLES (768px 991px)
========================================================================== */
@media (max-width: 991px) {
.custom-post-block {
padding: 25px 0;
font-size: 15px;
}
}
/* ==========================================================================
MOBILE PHONE RESPONSIVE STYLES (≤ 767px)
========================================================================== */
@media (max-width: 767px) {
.custom-post-block {
padding: 15px 0;
font-size: 14px;
}
}
`;
if (textarea.value.trim() === '') {
textarea.value = template;
} else {
textarea.value += '\n\n' + template;
}
textarea.focus();
}
var btnGenCssPost = document.getElementById('btnGenMediaQueryPost');
if (btnGenCssPost) {
btnGenCssPost.addEventListener('click', function () {
insertResponsiveMediaQueries(document.getElementById('postCustomCss'));
});
}
// Tab indent support for Code Textareas
function enableTabIndentation(textarea) {
if (!textarea) return;
textarea.addEventListener('keydown', function (e) {
if (e.key === 'Tab') {
e.preventDefault();
var start = this.selectionStart;
var end = this.selectionEnd;
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 2;
}
});
}
enableTabIndentation(document.getElementById('postCustomCss'));
enableTabIndentation(document.getElementById('postCustomJs'));
});
// --- Tag click-to-add helper ---
@@ -25,13 +25,13 @@
}
/*[[${themeModCustomCss}]]*/
</style>
<!-- Dedicated Page Custom CSS -->
<style th:if="${page != null and page.customCss != null and !page.customCss.empty}" th:utext="${page.customCss}"></style>
</head>
<body>
<!-- Render Editor.js Blocks -->
<div layout:fragment="content">
<!-- Dedicated Page Custom CSS -->
<style th:if="${page != null and page.customCss != null and !page.customCss.empty}" th:utext="${page.customCss}"></style>
<th:block th:each="block : ${blocks}">
<div
th:class="${(block.data != null and block.data['stretched'] == true) or (block.data != null and block.data['stretched'] == null and block['stretched'] == true) ? 'sis-fullwidth-breakout' : ''}"
@@ -387,8 +387,38 @@
</div>
</div>
</div>
</div>
</th:block>
</th:block>
<!-- 17. Dedicated Hover Cards Block -->
<th:block th:if="${block['type'] == 'hovercard' or block['type'] == 'hover-card' or block['type'] == 'hoverCard'}">
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : null}"
th:class="${(block.data['styleVariant'] == 'v2' or (block.data['globalClass'] != null and block.data['globalClass'].contains('v2'))) ? ('sis-hover-card-v2-section ' + (block.data['globalClass'] != null ? block.data['globalClass'] : '')) : ('sis-hover-card-section ' + (block.data['globalClass'] != null ? block.data['globalClass'] : ''))}">
<div class="container py-4">
<div class="row align-items-center">
<th:block th:each="item, iterStat : ${block.data['items']}">
<div th:class="${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'] == 6 ? 'col-lg-2 col-md-4 col-6 mb-4' : 'col-lg-3 col-md-6 col-12 mb-4')))}">
<div th:class="${(block.data['styleVariant'] == 'v2' or (block.data['globalClass'] != null and block.data['globalClass'].contains('v2'))) ? ('sis-hover-card sis-hover-card-v2 ' + ((item['active'] == true or (item['active'] == null and iterStat.first)) ? 'active' : '')) : ('sis-hover-card ' + ((item['active'] == true or (item['active'] == null and iterStat.first)) ? 'active' : ''))}">
<div class="w-full flex flex-col items-center justify-center transition-all duration-500">
<div class="sis-hover-card__icon" th:if="${item['imageUrl'] != null and !#strings.isEmpty(item['imageUrl'])}">
<img th:src="${item['imageUrl']}" class="sis-hover-card__img-main" th:alt="${item['title']}" />
<img th:if="${item['hoverImageUrl'] != null and !#strings.isEmpty(item['hoverImageUrl'])}" th:src="${item['hoverImageUrl']}" class="sis-hover-card__img-hover" th:alt="${item['title']}" />
</div>
<h3 class="sis-hover-card__title" th:utext="${item['title']}">Title</h3>
</div>
<p class="sis-hover-card__desc" th:utext="${item['description']}">Description</p>
</div>
</div>
</th:block>
</div>
</div>
</div>
</th:block>
<!-- 18. Custom HTML Tag Block (Opening or Closing Tag) -->
<th:block th:if="${block['type'] == 'tag'}">
<th:block th:if="${block.data['rawHtmlTag'] != null and !#strings.isEmpty(block.data['rawHtmlTag'])}"
th:utext="${block.data['rawHtmlTag']}"></th:block>
</th:block>
<!-- Shared Cell Content Renderer Fragment -->
<th:block th:fragment="renderCell(type, i, blockData)">
@@ -479,6 +509,37 @@
</section>
</th:block>
<!-- Nested Plugin: hovercard -->
<th:block th:if="${(type == 'hovercard' or type == 'hover-card' or type == 'hoverCard') and blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['items'] != null}">
<div th:id="${blockData['col' + i]['globalId'] != null and !#strings.isEmpty(blockData['col' + i]['globalId']) ? blockData['col' + i]['globalId'] : null}"
th:class="${(blockData['col' + i]['styleVariant'] == 'v2' or (blockData['col' + i]['globalClass'] != null and blockData['col' + i]['globalClass'].contains('v2'))) ? ('sis-hover-card-v2-section ' + (blockData['col' + i]['globalClass'] != null ? blockData['col' + i]['globalClass'] : '')) : ('sis-hover-card-section ' + (blockData['col' + i]['globalClass'] != null ? blockData['col' + i]['globalClass'] : ''))}">
<div class="container-fluid p-0">
<div class="row align-items-center">
<th:block th:each="item, iterStat : ${blockData['col' + i]['items']}">
<div th:class="${blockData['col' + i]['cols'] == 1 ? 'col-12 mb-4' : (blockData['col' + i]['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (blockData['col' + i]['cols'] == 3 ? 'col-lg-4 col-md-6 col-12 mb-4' : (blockData['col' + i]['cols'] == 6 ? 'col-lg-2 col-md-4 col-6 mb-4' : 'col-lg-3 col-md-6 col-12 mb-4')))}">
<div th:class="${(blockData['col' + i]['styleVariant'] == 'v2' or (blockData['col' + i]['globalClass'] != null and blockData['col' + i]['globalClass'].contains('v2'))) ? ('sis-hover-card sis-hover-card-v2 ' + ((item['active'] == true or (item['active'] == null and iterStat.first)) ? 'active' : '')) : ('sis-hover-card ' + ((item['active'] == true or (item['active'] == null and iterStat.first)) ? 'active' : ''))}">
<div class="w-full flex flex-col items-center justify-center transition-all duration-500">
<div class="sis-hover-card__icon" th:if="${item['imageUrl'] != null and !#strings.isEmpty(item['imageUrl'])}">
<img th:src="${item['imageUrl']}" class="sis-hover-card__img-main" th:alt="${item['title']}" />
<img th:if="${item['hoverImageUrl'] != null and !#strings.isEmpty(item['hoverImageUrl'])}" th:src="${item['hoverImageUrl']}" class="sis-hover-card__img-hover" th:alt="${item['title']}" />
</div>
<h3 class="sis-hover-card__title" th:utext="${item['title']}">Title</h3>
</div>
<p class="sis-hover-card__desc" th:utext="${item['description']}">Description</p>
</div>
</div>
</th:block>
</div>
</div>
</div>
</th:block>
<!-- Nested Plugin: tag -->
<th:block th:if="${type == 'tag' and blockData != null and blockData['col' + i] != null}">
<th:block th:if="${blockData['col' + i]['rawHtmlTag'] != null and !#strings.isEmpty(blockData['col' + i]['rawHtmlTag'])}"
th:utext="${blockData['col' + i]['rawHtmlTag']}"></th:block>
</th:block>
<!-- Nested Plugin: grid (Recursive) -->
<th:block th:if="${type == 'grid' and blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['cols'] != null}">
<div th:id="${blockData['col' + i]['globalId'] != null and !#strings.isEmpty(blockData['col' + i]['globalId']) ? blockData['col' + i]['globalId'] : null}"
@@ -630,6 +691,19 @@
});
}
});
// Interactive Hover Cards — Mouseenter transfers active class to hovered card
document.querySelectorAll('.sis-hover-card').forEach(function (card) {
card.addEventListener('mouseenter', function () {
var parentRow = card.closest('.row');
if (parentRow) {
parentRow.querySelectorAll('.sis-hover-card').forEach(function (c) {
c.classList.remove('active');
});
}
card.classList.add('active');
});
});
});
</script>
<!-- Dedicated Page Custom JS -->
@@ -5,13 +5,12 @@
<head>
<title th:text="${post.title} + ' | UMass Amherst'">Post Title | UMass Amherst</title>
<meta name="description" th:content="${post.metaDescription ?: post.excerpt}">
<!-- Dedicated Post Custom CSS & JS -->
<style th:if="${post != null and post.customCss != null and !post.customCss.empty}" th:utext="${post.customCss}"></style>
<script th:if="${post != null and post.customJs != null and !post.customJs.empty}" th:utext="${post.customJs}"></script>
</head>
<body>
<div layout:fragment="content">
<style th:if="${post != null and post.customCss != null and !post.customCss.empty}" th:utext="${post.customCss}"></style>
<script th:if="${post != null and post.customJs != null and !post.customJs.empty}" th:utext="${post.customJs}"></script>
<div id="block-umass-base-content" class="block block-system block-system-main-block tc--template-container tc--article">
<div class="t--template t--article">
@@ -5,51 +5,12 @@
<head>
<title th:text="${post.title} + ' | UMass Amherst'">Post Title | UMass Amherst</title>
<meta name="description" th:content="${post.metaDescription ?: post.excerpt}">
<!-- Dedicated Post Custom CSS & JS -->
<style th:if="${post != null and post.customCss != null and !post.customCss.empty}" th:utext="${post.customCss}"></style>
<script th:if="${post != null and post.customJs != null and !post.customJs.empty}" th:utext="${post.customJs}"></script>
<style>
.livestream-video-wrapper {
position: relative;
padding-bottom: 56.25%; /* 16:9 Aspect Ratio */
height: 0;
overflow: hidden;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
margin-bottom: 2rem;
background: #000;
}
.livestream-video-wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
.livestream-meta {
margin-bottom: 2rem;
border-bottom: 1px solid #eee;
padding-bottom: 1rem;
}
.livestream-title {
font-size: 2.25rem;
font-weight: 800;
color: #111;
margin-bottom: 0.5rem;
line-height: 1.25;
}
.livestream-date {
color: #881C1C;
font-weight: 700;
font-size: 0.95rem;
text-transform: uppercase;
}
</style>
</head>
<body>
<div layout:fragment="content">
<style th:if="${post != null and post.customCss != null and !post.customCss.empty}" th:utext="${post.customCss}"></style>
<script th:if="${post != null and post.customJs != null and !post.customJs.empty}" th:utext="${post.customJs}"></script>
<div id="block-umass-base-content" class="block block-system block-system-main-block tc--template-container tc--article">
<div class="t--template t--article">
@@ -5,13 +5,12 @@
<head>
<title th:text="${post.title} + ' | UMass Amherst'">Post Title | UMass Amherst</title>
<meta name="description" th:content="${post.metaDescription ?: post.excerpt}">
<!-- Dedicated Post Custom CSS & JS -->
<style th:if="${post != null and post.customCss != null and !post.customCss.empty}" th:utext="${post.customCss}"></style>
<script th:if="${post != null and post.customJs != null and !post.customJs.empty}" th:utext="${post.customJs}"></script>
</head>
<body>
<div layout:fragment="content">
<style th:if="${post != null and post.customCss != null and !post.customCss.empty}" th:utext="${post.customCss}"></style>
<script th:if="${post != null and post.customJs != null and !post.customJs.empty}" th:utext="${post.customJs}"></script>
<div id="block-umass-base-content" class="block block-system block-system-main-block tc--template-container tc--article">
<div class="t--template t--article">
@@ -5,13 +5,12 @@
<head>
<title th:text="${post.title} + ' | UMass Amherst'">Post Title | UMass Amherst</title>
<meta name="description" th:content="${post.metaDescription ?: post.excerpt}">
<!-- Dedicated Post Custom CSS & JS -->
<style th:if="${post != null and post.customCss != null and !post.customCss.empty}" th:utext="${post.customCss}"></style>
<script th:if="${post != null and post.customJs != null and !post.customJs.empty}" th:utext="${post.customJs}"></script>
</head>
<body>
<div layout:fragment="content-top">
<style th:if="${post != null and post.customCss != null and !post.customCss.empty}" th:utext="${post.customCss}"></style>
<script th:if="${post != null and post.customJs != null and !post.customJs.empty}" th:utext="${post.customJs}"></script>
<!-- Content Top (Banner 3 lớp kết hợp Title bài Post) -->
<div class="content-top">
<div class="lc--layout-container lc--full">
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+91 -112
View File
@@ -1,7 +1,8 @@
{
"time": 1700000000000,
"time": 1785560196216,
"blocks": [
{
"id": "HEo3OKYI07",
"type": "stickyNav",
"data": {
"title": "Tầm nhìn - Sứ mệnh",
@@ -31,146 +32,124 @@
"label": "Thành tựu và giải thưởng",
"link": "/page/thanh-tuu-va-giai-thuong"
}
]
],
"sticky": true,
"stretched": true
}
},
{
"id": "_9-7doWIJA",
"type": "grid",
"data": {
"cols": 2,
"globalClass": "",
"globalId": "",
"type1": "html",
"col1": "\n<div class=\"relative pt-4\">\n <div class=\"absolute md:-left-4 -left-3 md:-top-5 -top-2 md:size-12 size-8 bg-primary-100 rounded-full\"></div>\n <h2 class=\"display-3 text-primary-500 uppercase mb-4 block z-[1] relative\">TẦM NHÌN</h2>\n <div class=\"flex flex-col space-y-4\">\n <div class=\"bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow\">\n <p class=\"body-1 text-gray-800 text-justify !font-medium\">Trở thành bệnh viện đại học dẫn đầu Việt Nam đạt chuẩn quốc tế</p>\n </div>\n </div>\n</div>\n",
"width1": 6,
"imgUrl1": "",
"ytUrl1": "",
"accTitle1": "",
"accContent1": "",
"class1": "relative z-10",
"id1": "",
"width1": 6,
"type2": "image",
"col2": {
"file": {
"url": "/images/umass/mission.jpeg"
"extension": "webp",
"size": 98106,
"name": "dotquy203.webp",
"url": "/uploads/2026/08/dotquy203.webp"
},
"caption": "",
"withBorder": false,
"withBackground": false,
"stretched": false
"stretched": false,
"withBackground": false
},
"width2": 6,
"class2": "shadow-lg rounded"
}
},
{
"type": "grid",
"data": {
"cols": 2,
"type1": "image",
"col1": {
"file": {
"url": "/images/umass/vision.jpeg"
},
"caption": "",
"withBorder": false,
"withBackground": false,
"stretched": false
},
"width1": 6,
"class1": "shadow-lg rounded",
"type2": "html",
"col2": "\n<div class=\"relative pt-4\">\n <div class=\"absolute md:-left-5 -left-4 md:-top-5 -top-2 md:size-12 size-8 bg-primary-100 rounded-full\"></div>\n <h2 class=\"display-3 text-primary-500 mb-4 relative z-[1]\">SỨ MỆNH</h2>\n <div class=\"bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow\">\n <p class=\"body-1 text-primary-500 text-justify !font-medium\">Mang đến giải pháp chăm sóc sức khỏe tối ưu từ sự tích hợp giữa điều trị, nghiên cứu và đào tạo</p>\n </div>\n</div>\n",
"imgUrl2": "",
"ytUrl2": "",
"accTitle2": "",
"accContent2": "",
"class2": "shadow-lg rounded",
"id2": "",
"width2": 6
}
},
{
"id": "SL3DLP54-m",
"type": "grid",
"data": {
"cols": 4,
"type1": "flex",
"cols": 2,
"globalClass": "",
"globalId": "",
"type1": "image",
"col1": {
"cols": 2,
"direction": "column",
"justify": "center",
"align": "center",
"gap": "2",
"type1": "image",
"col1": {
"file": {
"url": "/images/career/core-hover-1.webp"
},
"caption": "",
"withBorder": false,
"withBackground": false,
"stretched": false
"file": {
"url": "/uploads/2026/07/dotquy201.webp"
},
"type2": "html",
"col2": "\n<div class=\"text-center\">\n <h3 class=\"heading-1 font-weight-bold mb-2\">Tiên phong</h3>\n <p class=\"title-1\">Phát triển và ứng dụng kỹ thuật tiên tiến vào điều trị, giảng dạy, nghiên cứu</p>\n</div>\n"
"caption": "",
"withBorder": false,
"stretched": false,
"withBackground": false
},
"width1": 3,
"class1": "rounded-lg shadow bg-primary-600 text-white p-4 h-full",
"type2": "flex",
"col2": {
"cols": 2,
"direction": "column",
"justify": "center",
"align": "center",
"gap": "2",
"type1": "image",
"col1": {
"file": {
"url": "/images/career/core-2.webp"
},
"caption": "",
"withBorder": false,
"withBackground": false,
"stretched": false
"imgUrl1": "",
"ytUrl1": "",
"accTitle1": "",
"accContent1": "",
"class1": "shadow-lg rounded",
"id1": "",
"width1": 6,
"type2": "html",
"col2": "\n<div class=\"relative pt-4\">\n <div class=\"absolute md:-left-5 -left-4 md:-top-5 -top-2 md:size-12 size-8 bg-primary-100 rounded-full\"></div>\n <h2 class=\"display-3 text-primary-500 mb-4 relative z-[1]\">SỨ MỆNH</h2>\n <div class=\"bg-white p-4 md:p-6 rounded-lg overview-wrapper-shadow\">\n <p class=\"body-1 text-primary-500 text-justify !font-medium\">Mang đến giải pháp chăm sóc sức khỏe tối ưu từ sự tích hợp giữa điều trị, nghiên cứu và đào tạo</p>\n </div>\n</div>\n",
"imgUrl2": "",
"ytUrl2": "",
"accTitle2": "",
"accContent2": "",
"class2": "",
"id2": "",
"width2": 6
}
},
{
"id": "bJ3ycvyeZT",
"type": "hovercard",
"data": {
"cols": 4,
"styleVariant": "v2",
"globalClass": "sis-hover-card-v2-section",
"globalId": "",
"items": [
{
"title": "Tiên phong",
"description": "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",
"imageUrl": "/images/career/core-1.webp",
"hoverImageUrl": "/images/career/core-hover-1.webp",
"active": true
},
"type2": "html",
"col2": "\n<div class=\"text-center\">\n <h3 class=\"heading-1 font-weight-bold mb-2\">Thấu hiểu</h3>\n <p class=\"title-1 text-gray-700\">Nỗi đau thể xác và tinh thần của người bệnh để đưa ra giải pháp điều trị tối ưu</p>\n</div>\n"
},
"width2": 3,
"class2": "rounded-lg shadow bg-white text-primary-600 p-4 h-full border border-primary-100",
"type3": "flex",
"col3": {
"cols": 2,
"direction": "column",
"justify": "center",
"align": "center",
"gap": "2",
"type1": "image",
"col1": {
"file": {
"url": "/images/career/core-3.webp"
},
"caption": "",
"withBorder": false,
"withBackground": false,
"stretched": false
{
"title": "Thấu hiểu",
"description": "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",
"imageUrl": "/images/career/core-2.webp",
"hoverImageUrl": "/images/career/core-hover-2.webp",
"active": false
},
"type2": "html",
"col2": "\n<div class=\"text-center\">\n <h3 class=\"heading-1 font-weight-bold mb-2\">Chuẩn mực</h3>\n <p class=\"title-1 text-gray-700\">Về y đức và học thuật của người thầy giáo thầy thuốc</p>\n</div>\n"
},
"width3": 3,
"class3": "rounded-lg shadow bg-white text-primary-600 p-4 h-full border border-primary-100",
"type4": "flex",
"col4": {
"cols": 2,
"direction": "column",
"justify": "center",
"align": "center",
"gap": "2",
"type1": "image",
"col1": {
"file": {
"url": "/images/career/core-4.webp"
},
"caption": "",
"withBorder": false,
"withBackground": false,
"stretched": false
{
"title": "Chuẩn mực",
"description": "Về y đức và học thuật của người thầy giáo thầy thuốc",
"imageUrl": "/images/career/core-3.webp",
"hoverImageUrl": "/images/career/core-hover-3.webp",
"active": false
},
"type2": "html",
"col2": "\n<div class=\"text-center\">\n <h3 class=\"heading-1 font-weight-bold mb-2\">An toàn</h3>\n <p class=\"title-1 text-gray-700\">Cho người bệnh, nhân viên y tế và cộng đồng</p>\n</div>\n"
},
"width4": 3,
"class4": "rounded-lg shadow bg-white text-primary-600 p-4 h-full border border-primary-100"
{
"title": "An toàn",
"description": "Cho người bệnh, nhân viên y tế và cộng đồng",
"imageUrl": "/images/career/core-4.webp",
"hoverImageUrl": "/images/career/core-hover-4.webp",
"active": false
}
]
}
}
],
"version": "2.30.8"
"version": "2.31.0-rc.7"
}