feat: implement dynamic training course tab system and modular PDF data migration scripts

This commit is contained in:
2026-07-27 17:00:43 +07:00
parent 54e420499f
commit 1a8726285c
98 changed files with 8041 additions and 3949 deletions
@@ -90,15 +90,19 @@ body, h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
}
.cc--tabbed-media-content .tab-labels-inner {
border-bottom-color: #cccccc !important; /* light gray line for the tab row */
border-bottom-color: #cccccc !important;
/* light gray line for the tab row */
}
.cc--tabbed-media-content .tab-label {
color: #555555 !important; /* dark gray for inactive tabs */
color: #555555 !important;
/* dark gray for inactive tabs */
font-weight: 700 !important;
}
.cc--tabbed-media-content .tab-label[aria-selected="true"] {
color: #000000 !important; /* solid black for active tab */
border-bottom: 3px solid #000000 !important; /* solid black underline for active tab */
color: #000000 !important;
/* solid black for active tab */
border-bottom: 3px solid #000000 !important;
/* solid black underline for active tab */
}
.cc--tabbed-media-content .tabbed-media-content-media-col {
@@ -572,7 +576,8 @@ body, h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
#map-section .branch-text {
color: #111827; /* text-gray-900 */
color: #111827;
/* text-gray-900 */
}
#map-section .branch-card {
@@ -581,12 +586,14 @@ body, h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
/* Active State */
#map-section .branch-card.active {
background-color: var(--color-old-brick); /* bg-primary-600 */
background-color: var(--color-old-brick);
/* bg-primary-600 */
}
#map-section .branch-card.active .branch-title,
#map-section .branch-card.active .branch-text,
#map-section .branch-card.active .branch-icon {
color: #ffffff; /* text-white */
color: #ffffff;
/* text-white */
}
/* Hero Background Override */
@@ -644,4 +651,482 @@ i, .btn-outline-primary {
.bg-linear-service {
background: linear-gradient(#60a7d700, var(--color-old-brick)) !important;
}
}
/* Training Detail Layout */
.training-section {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.training-layout {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.training-main {
flex: 1 1 100%;
min-width: 0;
}
.training-sidebar {
flex: 1 1 100%;
}
.training-sticky {
position: static;
}
@media (min-width: 768px) {
.training-section {
padding-top: 2rem;
padding-bottom: 2rem;
}
.training-layout {
gap: 1.5rem;
}
}
@media (min-width: 1024px) {
.training-container {
padding-left: 2.5rem;
padding-right: 2.5rem;
}
.training-layout {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.training-main {
grid-column: span 9 / span 9;
}
.training-sidebar {
grid-column: span 3 / span 3;
}
.training-sticky {
position: sticky;
top: 132px;
}
}
@media (min-width: 1280px) {
.training-section {
padding-top: 3rem;
padding-bottom: 3rem;
}
.training-layout {
gap: 2rem;
}
}
.education .responsive-flex-container {
max-width: 1280px;
margin: auto;
}
.education .bg-primary-600.rounded-lg.p-4.flex.flex-col.justify-center {
animation-composition: replace;
animation-delay: 0s;
animation-duration: 0s;
animation-iteration-count: 1;
animation-play-state: running;
animation-timing-function: ease;
backface-visibility: visible;
background-attachment: scroll;
background-clip: border-box;
background-color: rgb(13, 113, 186);
background-origin: padding-box;
background-position: 0% 0%;
background-repeat: repeat;
baseline-shift: 0px;
block-size: 388.609px;
border-block-end-color: rgb(0, 0, 0);
border-block-end-style: solid;
border-block-end-width: 0px;
border-block-start-color: rgb(0, 0, 0);
border-block-start-style: solid;
border-block-start-width: 0px;
border-bottom-color: rgb(0, 0, 0);
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
border-bottom-style: solid;
border-bottom-width: 0px;
border-collapse: separate;
border-end-end-radius: 8px;
border-end-start-radius: 8px;
border-image-outset: 0;
border-image-repeat: stretch;
border-image-slice: 100%;
border-image-width: 1;
border-inline-end-color: rgb(0, 0, 0);
border-inline-end-style: solid;
border-inline-end-width: 0px;
border-inline-start-color: rgb(0, 0, 0);
border-inline-start-style: solid;
border-inline-start-width: 0px;
border-left-color: rgb(0, 0, 0);
border-left-style: solid;
border-left-width: 0px;
border-right-color: rgb(0, 0, 0);
border-right-style: solid;
border-right-width: 0px;
border-start-end-radius: 8px;
border-start-start-radius: 8px;
border-top-color: rgb(0, 0, 0);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-top-style: solid;
border-top-width: 0px;
box-decoration-break: slice;
box-sizing: border-box;
caption-side: top;
caret-color: rgb(0, 0, 0);
clip-rule: nonzero;
color: rgb(0, 0, 0);
color-interpolation: srgb;
color-interpolation-filters: linearrgb;
column-fill: balance;
column-rule-color: rgb(0, 0, 0);
column-rule-inset-cap-end: 0px;
column-rule-inset-cap-start: 0px;
column-rule-inset-junction-end: 0px;
column-rule-inset-junction-start: 0px;
column-rule-width: 3px;
content-visibility: visible;
corner-bottom-left-shape: round;
corner-bottom-right-shape: round;
corner-end-end-shape: round;
corner-end-start-shape: round;
corner-start-end-shape: round;
corner-start-start-shape: round;
corner-top-left-shape: round;
corner-top-right-shape: round;
cx: 0px;
cy: 0px;
direction: ltr;
display: flex;
dynamic-range-limit: no-limit;
empty-cells: show;
field-sizing: fixed;
fill: rgb(0, 0, 0);
fill-opacity: 1;
fill-rule: nonzero;
flex-direction: column;
flex-grow: 0;
flex-line-count: 1;
flex-shrink: 1;
flex-wrap: nowrap;
flood-color: rgb(0, 0, 0);
flood-opacity: 1;
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 16px;
font-stretch: 100%;
font-weight: 400;
grid-auto-flow: row;
height: 388.609px;
hyphens: manual;
image-orientation: from-image;
inline-size: 276px;
inset-block-start: 132px;
interpolate-size: numeric-only;
justify-content: center;
lighting-color: rgb(255, 255, 255);
line-height: 24px;
list-style-position: outside;
list-style-type: disc;
margin-block-end: 0px;
margin-block-start: 0px;
margin-bottom: 0px;
margin-inline-end: 0px;
margin-inline-start: 0px;
margin-left: 0px;
margin-right: 0px;
margin-top: 0px;
mask-clip: border-box;
mask-composite: add;
mask-mode: match-source;
mask-origin: border-box;
mask-position: 0% 0%;
mask-repeat: repeat;
mask-type: luminance;
math-depth: 0;
min-block-size: 0px;
min-height: 0px;
min-inline-size: 0px;
min-width: 0px;
object-fit: fill;
object-position: 50% 50%;
offset-distance: 0px;
offset-rotate: auto 0deg;
opacity: 1;
order: 0;
orphans: 2;
outline-color: rgb(0, 0, 0);
outline-offset: 0px;
outline-width: 3px;
overflow-block: visible;
overflow-clip-margin: 0px;
overflow-inline: visible;
overflow-x: visible;
overflow-y: visible;
padding-block-end: 16px;
padding-block-start: 16px;
padding-bottom: 16px;
padding-inline-end: 16px;
padding-inline-start: 16px;
padding-left: 16px;
padding-right: 16px;
padding-top: 16px;
perspective-origin: 138px 194.297px;
position: sticky;
position-visibility: anchors-visible;
print-color-adjust: economy;
r: 0px;
reading-order: 0;
row-rule-color: rgb(0, 0, 0);
row-rule-inset-cap-end: 0px;
row-rule-inset-cap-start: 0px;
row-rule-inset-junction-end: 0px;
row-rule-inset-junction-start: 0px;
row-rule-width: 3px;
ruby-align: space-around;
ruby-position: over;
rule-overlap: row-over-column;
scroll-margin-block-end: 0px;
scroll-margin-block-start: 0px;
scroll-margin-bottom: 0px;
scroll-margin-inline-end: 0px;
scroll-margin-inline-start: 0px;
scroll-margin-left: 0px;
scroll-margin-right: 0px;
scroll-margin-top: 0px;
scroll-timeline-axis: block;
shape-image-threshold: 0;
shape-margin: 0px;
stop-color: rgb(0, 0, 0);
stop-opacity: 1;
stroke-dashoffset: 0px;
stroke-linecap: butt;
stroke-linejoin: miter;
stroke-miterlimit: 4;
stroke-opacity: 1;
stroke-width: 1px;
tab-size: 4;
text-align: start;
text-anchor: start;
text-autospace: no-autospace;
text-decoration-color: rgb(0, 0, 0);
text-decoration-style: solid;
text-emphasis-color: rgb(0, 0, 0);
text-emphasis-position: over;
text-indent: 0px;
text-orientation: mixed;
text-overflow: clip;
text-size-adjust: 100%;
text-wrap-mode: wrap;
top: 132px;
transform-box: view-box;
transform-origin: 138px 194.305px;
transform-style: flat;
transition-delay: 0s;
transition-duration: 0s;
transition-property: all;
transition-timing-function: ease;
unicode-bidi: isolate;
vertical-align: baseline;
view-timeline-axis: block;
visibility: visible;
white-space-collapse: collapse;
widows: 2;
width: 276px;
word-spacing: 0px;
writing-mode: horizontal-tb;
x: 0px;
y: 0px;
zoom: 1;
/* -webkit-border-horizontal-spacing: 0px;
-webkit-border-vertical-spacing: 0px;
-webkit-box-align: stretch;
-webkit-box-decoration-break: slice;
-webkit-box-flex: 0;
-webkit-box-ordinal-group: 1;
-webkit-box-orient: horizontal;
-webkit-box-pack: start;
-webkit-locale: "vi";
-webkit-mask-box-image-outset: 0;
-webkit-mask-box-image-repeat: stretch;
-webkit-mask-box-image-slice: 0 fill;
-webkit-mask-position-x: 0%;
-webkit-mask-position-y: 0%;
-webkit-rtl-ordering: logical;
-webkit-ruby-position: before;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-text-fill-color: rgb(0, 0, 0);
-webkit-text-orientation: vertical-right;
-webkit-text-stroke-color: rgb(0, 0, 0);
-webkit-text-stroke-width: 0px;
-webkit-user-modify: read-only;
-webkit-writing-mode: horizontal-tb;
--container-md: 28rem;
--tw-ring-offset-shadow: 0 0 #0000;
--color-blue-100: oklch(93.2% .032 255.585);
--tw-inset-shadow-alpha: 100%;
--tw-ring-shadow: 0 0 #0000;
--text-2xl: 1.5rem;
--aspect-video: 16/9;
--color-green-600: oklch(62.7% .194 149.214);
--leading-relaxed: 1.625;
--blur-lg: 16px;
--color-sky-500: oklch(68.5% .169 237.323);
--text-9xl: 8rem;
--tw-drop-shadow-alpha: 100%;
--font-weight-black: 900;
--text-lg: 1.125rem;
--color-neutral-600: oklch(43.9% 0 0);
--default-transition-timing-function: cubic-bezier(.4,0,.2,1);
--color-blue-700: oklch(48.8% .243 264.376);
--color-green-50: oklch(98.2% .018 155.826);
--tw-outline-style: solid;
--text-2xl--line-height: calc(2/1.5);
--color-green-100: oklch(96.2% .044 156.743);
--tw-inset-ring-shadow: 0 0 #0000;
--text-base--line-height: calc(1.5/1);
--spacing: .25rem;
--tw-border-spacing-x: 0px;
--color-red-200: oklch(88.5% .062 18.334);
--color-neutral-200: oklch(92.2% 0 0);
--tw-translate-z: 0;
--tw-gradient-via: rgba(0, 0, 0, 0);
--tw-scale-y: 1;
--font-mono: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
--container-6xl: 72rem;
--text-xl--line-height: calc(1.75/1.25);
--color-green-800: oklch(44.8% .119 151.328);
--font-weight-semibold: 600;
--container-3xl: 48rem;
--color-red-700: oklch(50.5% .213 27.518);
--text-sm: .875rem;
--tw-translate-y: 0;
--ease-out: cubic-bezier(0,0,.2,1);
--swiper-theme-color: #007aff;
--color-blue-500: oklch(62.3% .214 259.815);
--color-green-500: oklch(72.3% .219 149.579);
--animate-spin: spin 1s linear infinite;
--container-4xl: 56rem;
--text-9xl--line-height: 1;
--tw-inset-shadow: 0 0 #0000;
--tw-ring-offset-color: #fff;
--text-lg--line-height: calc(1.75/1.125);
--tw-divide-x-reverse: 0;
--tw-gradient-from: rgba(0, 0, 0, 0);
--tw-content: "";
--color-white: #fff;
--color-blue-400: oklch(70.7% .165 254.624);
--tw-shadow-alpha: 100%;
--tw-gradient-to: rgba(0, 0, 0, 0);
--leading-tight: 1.25;
--radius-sm: .25rem;
--color-blue-600: oklch(54.6% .245 262.881);
--font-sans: ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
--text-4xl: 2.25rem;
--color-green-200: oklch(92.5% .084 155.995);
--tw-scale-z: 1;
--tw-border-style: solid;
--font-weight-bold: 700;
--swiper-navigation-size: 44px;
--color-red-800: oklch(44.4% .177 26.899);
--color-red-600: oklch(57.7% .245 27.325);
--container-2xl: 42rem;
--color-red-50: oklch(97.1% .013 17.38);
--tw-shadow: 0 0 #0000;
--text-sm--line-height: calc(1.25/.875);
--tw-translate-x: 0;
--tw-ring-offset-width: 0px;
--container-sm: 24rem;
--color-blue-200: oklch(88.2% .059 254.128);
--color-blue-50: oklch(97% .014 254.604);
--color-red-500: oklch(63.7% .237 25.331);
--tw-gradient-via-position: 50%;
--text-3xl--line-height: calc(2.25/1.875);
--text-6xl: 3.75rem;
--text-3xl: 1.875rem;
--color-black: #000;
--text-xs--line-height: calc(1/.75);
--default-font-family: ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
--font-weight-light: 300;
--tw-space-x-reverse: 0;
--font-weight-normal: 400;
--tw-scale-x: 1;
--color-neutral-700: oklch(37.1% 0 0);
--text-xs: .75rem;
--font-weight-medium: 500;
--tw-gradient-to-position: 100%;
--tw-border-spacing-y: 0px;
--color-neutral-50: oklch(98.5% 0 0);
--default-transition-duration: .15s;
--radius-xl: .75rem;
--animate-pulse: pulse 2s cubic-bezier(.4,0,.6,1)infinite;
--radius-2xl: 1rem;
--tw-space-y-reverse: 0;
--text-6xl--line-height: 1;
--container-xl: 36rem;
--color-blue-800: oklch(42.4% .199 265.638);
--color-sky-700: oklch(50% .134 242.749);
--radius-lg: .5rem;
--text-4xl--line-height: calc(2.5/2.25);
--text-xl: 1.25rem;
--color-orange-300: oklch(83.7% .128 66.29);
--radius-md: .375rem;
--container-7xl: 80rem;
--text-base: 1rem;
--tw-gradient-from-position: 0px;
--default-mono-font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
--ease-in-out: cubic-bezier(.4,0,.2,1); */
}
/* CSS cho Tab Đa năng - Giao diện Mới (Segmented Control) */
.custom-tabs-nav-container {
display: flex;
flex-direction: column;
justify-content: center;
margin-bottom: 2rem;
width: 100%;
}
.tabs-container {
background-color: var(--color-old-brick); /* primary-600 */
border-radius: 8px; /* rounded-lg */
padding: 4px; /* p-1 */
display: flex;
gap: 4px; /* gap-x-1 */
width: 100%;
max-width: 860px;
}
@media (max-width: 768px) {
.tabs-container {
flex-direction: column;
width: 100%;
max-width: 100%;
}
}
.custom-tab-btn {
padding: 8px 16px; /* px-4 py-2 */
border: none;
background-color: transparent;
color: white;
cursor: pointer;
border-radius: 6px; /* rounded-md */
transition: all 0.2s ease-in-out;
font-weight: 600;
font-size: 16px;
flex: 1;
text-align: center;
min-width: 120px;
}
.custom-tab-btn:hover {
color: rgba(255, 255, 255, 0.8);
background-color: transparent;
}
.custom-tab-btn.active {
background-color: white !important;
color: var(--color-old-brick) !important; /* text-primary-600 */
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
@@ -37,10 +37,10 @@ window.SISEditorPlugins = window.SISEditorPlugins || {};
* @param {string} holderId - The DOM element ID for the editor container
* @param {string} hiddenInputId - The DOM element ID for the hidden input storing JSON
* @param {object|null} initialData - Pre-existing Editor.js JSON data to load
* @param {boolean} [skipSubmitHandler=false] - If true, prevents automatic form submission handling
* @returns {EditorJS} The editor instance
*/
function initSISEditor(holderId, hiddenInputId, initialData) {
'use strict';
function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler = false) {
// === Built-in Tools ===
var builtInTools = {
@@ -158,7 +158,7 @@ function initSISEditor(holderId, hiddenInputId, initialData) {
// === Form submission handler ===
// Ensure the latest content is saved before form submit
var form = document.querySelector('form');
if (form) {
if (form && !skipSubmitHandler) {
var submitHandler = function(event) {
event.preventDefault();
editor.save().then(function(outputData) {
@@ -0,0 +1,31 @@
class TabEndTool {
static get toolbox() {
return {
title: 'Kết thúc Tabs',
icon: '<svg viewBox="0 0 24 24" width="20" height="20"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>'
};
}
constructor({ data, api }) {
this.data = data;
this.api = api;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.style.padding = '10px';
this.wrapper.style.border = '2px dashed #dc3545';
this.wrapper.style.backgroundColor = '#fff3f3';
this.wrapper.style.margin = '20px 0';
this.wrapper.style.textAlign = 'center';
this.wrapper.style.fontWeight = 'bold';
this.wrapper.style.color = '#dc3545';
this.wrapper.innerHTML = '--- KẾT THÚC CỤM TABS ---';
return this.wrapper;
}
save(blockContent) {
return {};
}
}
@@ -0,0 +1,65 @@
/**
* Tab Split Block Plugin for Editor.js
*
* Allows users to insert a visual "Tab Split" marker in the editor.
* When saved, it outputs `{ type: 'tabSplit', data: {} }`.
* The frontend parser will use this block to split content into tabs.
*/
class TabSplitTool {
/**
* Notify Editor.js that this tool doesn't require any inline toolbar
*/
static get toolbox() {
return {
title: 'Tab Split',
icon: '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"/></svg>'
};
}
/**
* Allow rendering without data (when inserted from the toolbox)
*/
static get isReadOnlySupported() {
return true;
}
constructor({ data, config, api }) {
this.api = api;
this.data = data || {};
this.wrapper = undefined;
}
/**
* Render the UI for this block
* @returns {HTMLElement}
*/
render() {
this.wrapper = document.createElement('div');
this.wrapper.classList.add('ce-tab-split');
this.wrapper.style.textAlign = 'center';
this.wrapper.style.padding = '15px 0';
this.wrapper.style.margin = '10px 0';
this.wrapper.style.borderTop = '2px dashed #4e73df';
this.wrapper.style.borderBottom = '2px dashed #4e73df';
this.wrapper.style.backgroundColor = '#f8f9fc';
this.wrapper.style.color = '#4e73df';
this.wrapper.style.fontWeight = 'bold';
this.wrapper.style.fontSize = '14px';
this.wrapper.style.textTransform = 'uppercase';
this.wrapper.style.letterSpacing = '2px';
this.wrapper.style.userSelect = 'none';
this.wrapper.innerHTML = '<span><i class="fas fa-columns mr-2"></i> --- TAB SPLIT --- </span>';
return this.wrapper;
}
/**
* Save data from this block
* @param {HTMLElement} blockContent
* @returns {Object}
*/
save(blockContent) {
return {}; // We only care about the block type, no inner data needed
}
}
@@ -0,0 +1,50 @@
class TabStartTool {
static get toolbox() {
return {
title: 'Bắt đầu Tab',
icon: '<svg viewBox="0 0 24 24" width="20" height="20"><path d="M3 3h18v18H3V3zm16 16V7H5v12h14zM7 9h10v2H7V9z"/></svg>'
};
}
constructor({ data, api }) {
this.data = {
title: data.title || ''
};
this.api = api;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.style.padding = '15px';
this.wrapper.style.border = '2px dashed #007bff';
this.wrapper.style.backgroundColor = '#f8f9fa';
this.wrapper.style.margin = '20px 0';
this.wrapper.style.borderRadius = '5px';
const label = document.createElement('div');
label.innerHTML = '<span style="color:#007bff;font-weight:bold;margin-right:10px;">[ THẺ TAB MỚI ]</span> Nhập tên thẻ Tab (Ví dụ: Tổng quan, Lịch học, Học phí...):';
label.style.marginBottom = '10px';
const input = document.createElement('input');
input.type = 'text';
input.value = this.data.title;
input.placeholder = 'Tên Tab...';
input.classList.add('form-control');
input.addEventListener('input', (event) => {
this.data.title = event.target.value;
});
this.wrapper.appendChild(label);
this.wrapper.appendChild(input);
return this.wrapper;
}
save(blockContent) {
const input = blockContent.querySelector('input');
return {
title: input ? input.value : this.data.title
};
}
}
File diff suppressed because one or more lines are too long
@@ -109,6 +109,45 @@
</div>
</section>
<section class="xl:py-12 md:py-8 py-6 bg-[#f6f6f6] xl:space-y-12 md:space-y-8 space-y-6">
<div class="lg:container max-lg:pl-5 max-md:px-0" th:if="${daoTaoPosts != null and not #lists.isEmpty(daoTaoPosts)}">
<div class="flex justify-between items-end md:mb-6 mb-4 lg:mb-8 max-lg:pr-5 max-md:px-4">
<div>
<h2 class="display-7 text-primary-600">Khóa học Đào tạo</h2>
</div>
<div class="flex items-center gap-4">
<div class="flex md:gap-2 gap-1">
<button class="btn-navigation-khoa-hoc-prev md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group swiper-button-disabled swiper-button-lock" disabled="">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90">
<path d="m18 15-6-6-6 6"></path>
</svg>
</button>
<button class="btn-navigation-khoa-hoc-next md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group swiper-button-disabled swiper-button-lock" disabled="">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90">
<path d="m6 9 6 6 6-6"></path>
</svg>
</button>
</div>
</div>
</div>
<div class="relative max-md:px-4 md:mb-6 mb-4 lg:mb-8">
<div class="swiper swiper-khoa-hoc w-full">
<div class="swiper-wrapper">
<th:block th:each="post : ${daoTaoPosts}">
<div class="swiper-slide h-auto max-w-[288px] mr-2 lg:!mr-3 xl:!mr-4" style="width: 288px; max-width: 100%;">
<a th:href="@{/dao-tao/{slug}(slug=${post.slug})}" class="group h-full flex flex-col items-start gap-y-4 lg:gap-y-5 rounded-2xl bg-white p-4 pb-5 shadow-sm transition-shadow hover:shadow-md border border-transparent hover:border-primary-100">
<div class="relative w-full aspect-square overflow-hidden rounded-xl">
<img th:src="${post.imageUrl != null and !#strings.isEmpty(post.imageUrl) ? post.imageUrl : '/images/dao-tao/training-1.jpeg'}" th:alt="${post.title}" class="absolute inset-0 size-full object-cover transition-transform duration-500 group-hover:scale-105">
</div>
<div class="flex flex-1 flex-col justify-between w-full">
<h3 class="heading-5 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-3" th:text="${post.title}">Tiêu đề khóa học</h3>
</div>
</a>
</div>
</th:block>
</div>
</div>
</div>
</div>
<div class="lg:container max-lg:pl-5 max-md:px-0">
<div class="flex justify-between items-end md:mb-6 mb-4 lg:mb-8 max-lg:pr-5 max-md:px-4">
<div>
@@ -644,6 +683,11 @@
prev: '.btn-navigation-gallery-prev',
next: '.btn-navigation-gallery-next',
pagination: '.swiper-pagination-training-gallery'
}, {
slug: 'khoa-hoc',
prev: '.btn-navigation-khoa-hoc-prev',
next: '.btn-navigation-khoa-hoc-next',
pagination: ''
}, {
slug: 'chung-chi',
prev: '.btn-navigation-cap-chung-chi-prev',
@@ -268,9 +268,16 @@
<!-- Init Editor -->
<script th:src="@{/js/manage/editor-config.js}"></script>
<!-- Initialize the editor with existing content (if editing) -->
<!-- Initialize the editor and page-specific scripts -->
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
// Register RawTool for imported HTML content
window.SISEditorPlugins = window.SISEditorPlugins || {};
if (typeof RawTool !== 'undefined') {
window.SISEditorPlugins['raw'] = { class: RawTool };
}
// --- Editor.js Initialization ---
var existingContent = document.getElementById('editorContent').value;
var initialData = null;
@@ -278,7 +285,18 @@
try {
initialData = JSON.parse(existingContent);
} catch (e) {
console.warn('[SIS Editor] Existing content is not valid JSON, starting fresh.');
console.warn('[SIS Editor] Existing content is not valid JSON, treating as Raw HTML.');
initialData = {
time: Date.now(),
blocks: [
{
type: "raw",
data: {
html: existingContent
}
}
]
};
}
}
@@ -260,8 +260,10 @@
<option th:each="s : ${statuses}" th:value="${s}" th:text="${s}"></option>
</select>
</div>
<div class="form-group mt-3">
<label for="postLayout" class="font-weight-bold">Layout</label>
<div class="form-group mb-0">
<label for="postLayout" class="font-weight-bold">
<i class="fas fa-columns"></i> Layout
</label>
<select class="form-control" id="postLayout" th:field="*{layout}">
<option th:each="l : ${layouts}" th:value="${l}" th:text="${l}"></option>
</select>
@@ -382,9 +384,14 @@
<script src="https://cdn.jsdelivr.net/npm/@editorjs/underline@1.1.0/dist/bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/image@2.9.0/dist/image.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/attaches@1.3.0/dist/bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/raw@2.4.0/dist/bundle.js"></script>
<!-- Custom SIS Editor Plugins -->
<script th:src="@{/js/manage/editor-plugins/html-snippet.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tab-split.js}"></script>
<script th:src="@{/js/manage/editor-plugins/training-course.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tab-start.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tab-end.js}"></script>
<!-- Init Editor -->
<script th:src="@{/js/manage/editor-config.js}"></script>
@@ -392,6 +399,24 @@
<!-- Initialize the editor and page-specific scripts -->
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
// Register Plugins
window.SISEditorPlugins = window.SISEditorPlugins || {};
if (typeof RawTool !== 'undefined') {
window.SISEditorPlugins['raw'] = { class: RawTool };
}
if (typeof TabSplitTool !== 'undefined') {
window.SISEditorPlugins['tabSplit'] = { class: TabSplitTool };
}
if (typeof TrainingCourseInfoTool !== 'undefined') {
}
if (typeof TabStartTool !== 'undefined') {
window.SISEditorPlugins['tabStart'] = { class: TabStartTool };
}
if (typeof TabEndTool !== 'undefined') {
window.SISEditorPlugins['tabEnd'] = { class: TabEndTool };
}
// --- Editor.js Initialization ---
var existingContent = document.getElementById('editorContent').value;
var initialData = null;
@@ -400,7 +425,18 @@
try {
initialData = JSON.parse(existingContent);
} catch (e) {
console.warn('[SIS Editor] Existing content is not valid JSON, starting fresh.');
console.warn('[SIS Editor] Existing content is not valid JSON, treating as Raw HTML.');
initialData = {
time: Date.now(),
blocks: [
{
type: "raw",
data: {
html: existingContent
}
}
]
};
}
}
@@ -490,7 +526,7 @@
var eventTimeGroup = document.getElementById('eventTimeGroup');
var eventLocationGroup = document.getElementById('eventLocationGroup');
function toggleEventTime() {
if (layoutSelect.value === 'EVENT') {
if (layoutSelect.value === 'EVENT' || layoutSelect.value === 'TRAINING') {
eventTimeGroup.style.display = 'block';
if (eventLocationGroup) eventLocationGroup.style.display = 'block';
} else {
@@ -58,8 +58,14 @@
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
data.blocks.forEach(function(block) {
switch(block.type) {
var inTabs = false;
var tabsGroup = null;
function renderSingleBlock(block) {
var html = "";
switch(block.type) {
case "header":
html += "<h" + block.data.level + ">" + block.data.text + "</h" + block.data.level + ">";
break;
@@ -80,10 +86,115 @@
case "quote":
html += "<blockquote>" + block.data.text + " <cite>" + block.data.caption + "</cite></blockquote>";
break;
default:
console.log("Unknown block type", block.type);
}
});
case "raw":
html += block.data.html;
break;
case "tabStart":
case "tabEnd":
break;
default:
console.log("Unknown block type", block.type);
}
return html;
}
var blockList = data && data.blocks ? data.blocks : (typeof blocks !== 'undefined' ? blocks : []);
var finalHtml = "";
blockList.forEach(function(block) {
if (block.type === 'tabStart') {
if (!inTabs) {
inTabs = true;
tabsGroup = { tabs: [] };
}
tabsGroup.tabs.push({
title: block.data.title || 'Tab',
contentHtml: ''
});
return;
}
if (block.type === 'tabEnd') {
if (inTabs && tabsGroup) {
inTabs = false;
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
tabsGroup = null;
}
return;
}
var blockHtml = renderSingleBlock(block);
if (inTabs && tabsGroup && tabsGroup.tabs.length > 0) {
tabsGroup.tabs[tabsGroup.tabs.length - 1].contentHtml += blockHtml;
} else {
finalHtml += blockHtml;
}
});
if (inTabs && tabsGroup) {
// Close unclosed tabs at the end
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
}
html = finalHtml;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var container = btn.closest('.custom-tabs-wrapper');
if (!container) return;
var idx = btn.getAttribute('data-tab-idx');
container.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
container.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = container.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
// Assign back to whatever variable the original script used.
}
parsedContainer.innerHTML = html;
} catch(e) {
@@ -111,8 +111,14 @@
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
data.blocks.forEach(function(block) {
switch(block.type) {
var inTabs = false;
var tabsGroup = null;
function renderSingleBlock(block) {
var html = "";
switch(block.type) {
case "paragraph":
html += "<p>" + block.data.text + "</p>";
break;
@@ -130,6 +136,117 @@
case "image":
html += "<figure><img src='" + block.data.file.url + "' alt='" + (block.data.caption || "") + "'><figcaption>" + (block.data.caption || "") + "</figcaption></figure>";
break;
case "tabStart":
case "tabEnd":
break;
default:
console.log("Unknown block type", block.type);
}
return html;
}
var blockList = data && data.blocks ? data.blocks : (typeof blocks !== 'undefined' ? blocks : []);
var finalHtml = "";
blockList.forEach(function(block) {
if (block.type === 'tabStart') {
if (!inTabs) {
inTabs = true;
tabsGroup = { tabs: [] };
}
tabsGroup.tabs.push({
title: block.data.title || 'Tab',
contentHtml: ''
});
return;
}
if (block.type === 'tabEnd') {
if (inTabs && tabsGroup) {
inTabs = false;
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
tabsGroup = null;
}
return;
}
var blockHtml = renderSingleBlock(block);
if (inTabs && tabsGroup && tabsGroup.tabs.length > 0) {
tabsGroup.tabs[tabsGroup.tabs.length - 1].contentHtml += blockHtml;
} else {
finalHtml += blockHtml;
}
});
if (inTabs && tabsGroup) {
// Close unclosed tabs at the end
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
}
html = finalHtml;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var container = btn.closest('.custom-tabs-wrapper');
if (!container) return;
var idx = btn.getAttribute('data-tab-idx');
container.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
container.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = container.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
// Assign back to whatever variable the original script used.
html += "</" + tag + ">";
break;
case "image":
html += "<figure><img src='" + block.data.file.url + "' alt='" + (block.data.caption || "") + "'><figcaption>" + (block.data.caption || "") + "</figcaption></figure>";
break;
default:
break;
}
@@ -104,8 +104,14 @@
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
data.blocks.forEach(function(block) {
switch(block.type) {
var inTabs = false;
var tabsGroup = null;
function renderSingleBlock(block) {
var html = "";
switch(block.type) {
case "header":
html += "<h" + block.data.level + ">" + block.data.text + "</h" + block.data.level + ">";
break;
@@ -126,10 +132,115 @@
case "quote":
html += "<blockquote>" + block.data.text + " <cite>" + block.data.caption + "</cite></blockquote>";
break;
default:
console.log("Unknown block type", block.type);
}
});
case "raw":
html += block.data.html;
break;
case "tabStart":
case "tabEnd":
break;
default:
console.log("Unknown block type", block.type);
}
return html;
}
var blockList = data && data.blocks ? data.blocks : (typeof blocks !== 'undefined' ? blocks : []);
var finalHtml = "";
blockList.forEach(function(block) {
if (block.type === 'tabStart') {
if (!inTabs) {
inTabs = true;
tabsGroup = { tabs: [] };
}
tabsGroup.tabs.push({
title: block.data.title || 'Tab',
contentHtml: ''
});
return;
}
if (block.type === 'tabEnd') {
if (inTabs && tabsGroup) {
inTabs = false;
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
tabsGroup = null;
}
return;
}
var blockHtml = renderSingleBlock(block);
if (inTabs && tabsGroup && tabsGroup.tabs.length > 0) {
tabsGroup.tabs[tabsGroup.tabs.length - 1].contentHtml += blockHtml;
} else {
finalHtml += blockHtml;
}
});
if (inTabs && tabsGroup) {
// Close unclosed tabs at the end
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
}
html = finalHtml;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var container = btn.closest('.custom-tabs-wrapper');
if (!container) return;
var idx = btn.getAttribute('data-tab-idx');
container.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
container.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = container.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
// Assign back to whatever variable the original script used.
}
parsedContainer.innerHTML = html;
} catch(e) {
@@ -177,8 +177,14 @@
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
data.blocks.forEach(function(block) {
switch(block.type) {
var inTabs = false;
var tabsGroup = null;
function renderSingleBlock(block) {
var html = "";
switch(block.type) {
case "header":
html += "<h" + block.data.level + ">" + block.data.text + "</h" + block.data.level + ">";
break;
@@ -199,10 +205,115 @@
case "quote":
html += "<blockquote>" + block.data.text + " <cite>" + block.data.caption + "</cite></blockquote>";
break;
default:
console.log("Unknown block type", block.type);
}
});
case "raw":
html += block.data.html;
break;
case "tabStart":
case "tabEnd":
break;
default:
console.log("Unknown block type", block.type);
}
return html;
}
var blockList = data && data.blocks ? data.blocks : (typeof blocks !== 'undefined' ? blocks : []);
var finalHtml = "";
blockList.forEach(function(block) {
if (block.type === 'tabStart') {
if (!inTabs) {
inTabs = true;
tabsGroup = { tabs: [] };
}
tabsGroup.tabs.push({
title: block.data.title || 'Tab',
contentHtml: ''
});
return;
}
if (block.type === 'tabEnd') {
if (inTabs && tabsGroup) {
inTabs = false;
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
tabsGroup = null;
}
return;
}
var blockHtml = renderSingleBlock(block);
if (inTabs && tabsGroup && tabsGroup.tabs.length > 0) {
tabsGroup.tabs[tabsGroup.tabs.length - 1].contentHtml += blockHtml;
} else {
finalHtml += blockHtml;
}
});
if (inTabs && tabsGroup) {
// Close unclosed tabs at the end
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
}
html = finalHtml;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var container = btn.closest('.custom-tabs-wrapper');
if (!container) return;
var idx = btn.getAttribute('data-tab-idx');
container.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
container.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = container.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
// Assign back to whatever variable the original script used.
}
parsedContainer.innerHTML = html;
} catch(e) {