refactor: clean up dao-tao-detail.html styling and implement course-info-widget fragment

This commit is contained in:
2026-07-27 20:49:53 +07:00
parent 1a8726285c
commit bc27a9ea9c
12 changed files with 902 additions and 987 deletions
+103
View File
@@ -0,0 +1,103 @@
import os
import re
script_replacement = """ <!-- Content rendering and dynamic tabs parsing -->
<script>
document.addEventListener("DOMContentLoaded", function() {
var rawData = document.getElementById("postContentRaw").textContent;
var parsedContainer = document.getElementById("postContentParsed");
// Content is now HTML
parsedContainer.innerHTML = rawData;
// Parse dynamic tabs from HTML (migrated from Editor.js)
var tabStarts = Array.from(parsedContainer.querySelectorAll('h3.dynamic-tab-start'));
if (tabStarts.length > 0) {
var tabsGroup = [];
var wrapper = document.createElement('div');
wrapper.className = 'custom-tabs-wrapper';
var navContainer = document.createElement('div');
navContainer.className = 'custom-tabs-nav-container';
var tabsContainer = document.createElement('div');
tabsContainer.className = 'tabs-container';
navContainer.appendChild(tabsContainer);
wrapper.appendChild(navContainer);
var firstTabStart = tabStarts[0];
var parentNode = firstTabStart.parentNode;
// Collect all elements into tabs
tabStarts.forEach(function(startEl, index) {
var tabTitle = startEl.textContent;
var btn = document.createElement('button');
btn.className = 'dao-tao-tab-btn custom-tab-btn' + (index === 0 ? ' active' : '');
btn.setAttribute('data-tab-idx', index);
btn.textContent = tabTitle;
tabsContainer.appendChild(btn);
var panel = document.createElement('div');
panel.className = 'dao-tao-tab-panel custom-tab-panel';
panel.setAttribute('data-tab-idx', index);
panel.style.display = (index === 0) ? 'block' : 'none';
var currentEl = startEl.nextElementSibling;
while (currentEl && !currentEl.classList.contains('dynamic-tab-start') && !currentEl.classList.contains('dynamic-tab-end')) {
var nextEl = currentEl.nextElementSibling;
panel.appendChild(currentEl);
currentEl = nextEl;
}
// Remove the dynamic-tab-end if present
if (currentEl && currentEl.classList.contains('dynamic-tab-end')) {
currentEl.parentNode.removeChild(currentEl);
}
wrapper.appendChild(panel);
startEl.parentNode.removeChild(startEl);
});
parentNode.appendChild(wrapper);
// Tab click listener
wrapper.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var idx = btn.getAttribute('data-tab-idx');
wrapper.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
wrapper.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = wrapper.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
}
});
</script>"""
files = [
'src/main/resources/templates/posts/full-width.html',
'src/main/resources/templates/posts/livestream.html',
'src/main/resources/templates/posts/sidebar.html'
]
for file_path in files:
with open(file_path, 'r') as f:
content = f.read()
# Regex to match the old Editor.js script block
pattern = re.compile(r'<!-- Include Editor\.js parser.*?<\/script>', re.DOTALL)
new_content = pattern.sub(script_replacement, content)
with open(file_path, 'w') as f:
f.write(new_content)
print(f"Updated {file_path}")
+94
View File
@@ -0,0 +1,94 @@
import re
script_replacement = """ <!-- Content rendering and dynamic tabs parsing -->
<script>
document.addEventListener("DOMContentLoaded", function() {
var rawData = document.getElementById("postContentRaw").textContent;
var parsedContainer = document.getElementById("postContentParsed");
// Content is now HTML
parsedContainer.innerHTML = rawData;
// Parse dynamic tabs from HTML (migrated from Editor.js)
var tabStarts = Array.from(parsedContainer.querySelectorAll('h3.dynamic-tab-start'));
if (tabStarts.length > 0) {
var tabsGroup = [];
var wrapper = document.createElement('div');
wrapper.className = 'custom-tabs-wrapper training-course-tabs-wrapper';
var navContainer = document.createElement('div');
navContainer.className = 'custom-tabs-nav-container';
var tabsContainer = document.createElement('div');
tabsContainer.className = 'tabs-container';
navContainer.appendChild(tabsContainer);
wrapper.appendChild(navContainer);
var firstTabStart = tabStarts[0];
var parentNode = firstTabStart.parentNode;
// Collect all elements into tabs
tabStarts.forEach(function(startEl, index) {
var tabTitle = startEl.textContent;
var btn = document.createElement('button');
btn.className = 'dao-tao-tab-btn custom-tab-btn' + (index === 0 ? ' active' : '');
btn.setAttribute('data-tab-idx', index);
btn.textContent = tabTitle;
tabsContainer.appendChild(btn);
var panel = document.createElement('div');
panel.className = 'dao-tao-tab-panel custom-tab-panel';
panel.setAttribute('data-tab-idx', index);
panel.style.display = (index === 0) ? 'block' : 'none';
var currentEl = startEl.nextElementSibling;
while (currentEl && !currentEl.classList.contains('dynamic-tab-start') && !currentEl.classList.contains('dynamic-tab-end')) {
var nextEl = currentEl.nextElementSibling;
panel.appendChild(currentEl);
currentEl = nextEl;
}
// Remove the dynamic-tab-end if present
if (currentEl && currentEl.classList.contains('dynamic-tab-end')) {
currentEl.parentNode.removeChild(currentEl);
}
wrapper.appendChild(panel);
startEl.parentNode.removeChild(startEl);
});
parentNode.appendChild(wrapper);
// Tab click listener
wrapper.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var idx = btn.getAttribute('data-tab-idx');
wrapper.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
wrapper.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = wrapper.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
}
});
</script>"""
file_path = 'src/main/resources/templates/dao-tao-detail.html'
with open(file_path, 'r') as f:
content = f.read()
# Pattern matches from <!-- Tab Switching + Editor.js Parser Script --> to the end of the <script> tag before </div>
pattern = re.compile(r'<!-- Tab Switching \+ Editor\.js Parser Script -->.*?<\/script>', re.DOTALL)
new_content = pattern.sub(script_replacement, content)
with open(file_path, 'w') as f:
f.write(new_content)
print(f"Updated {file_path}")
@@ -1,191 +1 @@
<style>
.education-slider-wrapper {
position: relative;
padding: 0px;
/* Space for buttons */
}
.education-slider-container {
overflow: hidden;
width: 100%;
}
.education-slider-wrapper .news-grid,
.education-slider-wrapper .row,
.education-slider-track {
display: flex !important;
flex-wrap: nowrap !important;
gap: 20px;
margin: 0 !important;
transition: transform 0.4s ease;
}
/* Force children to be fixed width cards */
.education-slider-wrapper .news-grid>*,
.education-slider-wrapper .row>*,
.education-slider-track>* {
flex: 0 0 calc(33.333% - 14px) !important;
/* show 3 cards at a time */
max-width: none !important;
}
@media (max-width: 992px) {
.education-slider-wrapper .news-grid>*,
.education-slider-wrapper .row>*,
.education-slider-track>* {
flex: 0 0 calc(50% - 10px) !important;
}
}
@media (max-width: 576px) {
.education-slider-wrapper .news-grid>*,
.education-slider-wrapper .row>*,
.education-slider-track>* {
flex: 0 0 100% !important;
}
}
.edu-slider-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
background-color: rgba(200, 200, 200, 0.5);
/* mờ / transparent */
color: #333;
border: none;
font-size: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: background-color 0.2s, color 0.2s;
line-height: 1;
}
.edu-slider-btn:hover {
background-color: rgba(0, 0, 0, 0.7);
color: #fff;
}
.edu-slider-btn.prev-btn {
left: -10px;
}
.edu-slider-btn.next-btn {
right: -10px;
}
.edu-slider-btn:disabled {
opacity: 0.2;
cursor: not-allowed;
}
</style>
<body>
<div class="cc--component-container cc--news-events">
<div class="c--component c--news-events">
<!-- Header -->
<div class="news-events-row">
<div class="education-column news-column" style="width: 100%;">
<div
style="display: flex; justify-content: space-between; align-items: baseline; border-bottom: 2px solid var(--color-brand); margin-bottom: 16px;">
<!-- Thêm flex-grow: 1 vào style của h2 -->
<h2 class="section-title--news"
style="font-size: 2rem; margin: 0; padding-bottom: 4px; border-bottom: unset;">ĐÀO TẠO</h2>
<a href="/news/education" class="news-events-section__cta news-events-section__cta--desktop"
style="color: var(--color-brand); font-weight: 800; font-size: 16px; text-transform: uppercase; text-decoration: none; border-bottom: unset;">XEM
THÊM</a>
</div>
<div class="education-slider-wrapper" style="padding-right: 0;">
<button class="edu-slider-btn prev-btn" aria-label="Previous">&lsaquo;</button>
<div class="education-slider-container">
<div class="education-slider-track">
[component:news-grid data-source="posts:tag=education,limit=5,collection=news_posts"]
</div>
</div>
<button class="edu-slider-btn next-btn" aria-label="Next">&rsaquo;</button>
</div>
</div>
</div><!-- Mobile Footer CTA -->
<div class="news-events-section__footer"><a href="/news/education"
class="news-events-section__cta news-events-section__cta--mobile">Xem Thêm</a></div>
</div>
</div>
</body>
<script>
document.addEventListener("DOMContentLoaded", function() {
const wrappers = document.querySelectorAll('.education-slider-wrapper');
wrappers.forEach(wrapper => {
const prevBtn = wrapper.querySelector('.prev-btn');
const nextBtn = wrapper.querySelector('.next-btn');
let track = wrapper.querySelector('.education-slider-track');
if (track && track.children.length === 1 && (track.children[0].classList.contains('news-grid') || track.children[0].classList.contains('row'))) {
track = track.children[0];
track.classList.add('education-slider-track');
}
if (!track) return;
let currentIndex = 0;
function updateSlider() {
const items = track.children;
if(items.length === 0) return;
const itemWidth = items[0].getBoundingClientRect().width;
const gap = parseFloat(window.getComputedStyle(track).gap) || 20;
track.style.transform = `translateX(-${currentIndex * (itemWidth + gap)}px)`;
const containerWidth = wrapper.querySelector('.education-slider-container').getBoundingClientRect().width;
const visibleCount = Math.max(1, Math.floor((containerWidth + gap) / (itemWidth + gap)));
prevBtn.disabled = currentIndex === 0;
nextBtn.disabled = currentIndex >= items.length - visibleCount;
}
prevBtn.addEventListener('click', () => {
if (currentIndex > 0) {
currentIndex--;
updateSlider();
}
});
nextBtn.addEventListener('click', () => {
const items = track.children;
const itemWidth = items[0].getBoundingClientRect().width;
const gap = parseFloat(window.getComputedStyle(track).gap) || 20;
const containerWidth = wrapper.querySelector('.education-slider-container').getBoundingClientRect().width;
const visibleCount = Math.max(1, Math.floor((containerWidth + gap) / (itemWidth + gap)));
if (currentIndex < items.length - visibleCount) {
currentIndex++;
updateSlider();
}
});
window.addEventListener('resize', () => {
const items = track.children;
if(items.length === 0) return;
const itemWidth = items[0].getBoundingClientRect().width;
const gap = parseFloat(window.getComputedStyle(track).gap) || 20;
const containerWidth = wrapper.querySelector('.education-slider-container').getBoundingClientRect().width;
const visibleCount = Math.max(1, Math.floor((containerWidth + gap) / (itemWidth + gap)));
if (currentIndex > items.length - visibleCount) {
currentIndex = Math.max(0, items.length - visibleCount);
}
updateSlider();
});
setTimeout(updateSlider, 200);
});
});
</script>
<table class='table table-bordered'><tbody><tr><td>TT</td><td>Tên bài</td><td>Mục tiêu bài học</td><td>Số tiết học</td><td></td><td></td><td></td></tr><tr><td></td><td></td><td></td><td>Tổn<br>g số</td><td>Lý<br>thuyế<br>t</td><td>Thực hành</td><td></td></tr><tr><td></td><td></td><td></td><td></td><td></td><td>Lab</td><td>Buồn<br>g<br>bệnh</td></tr><tr><td>01</td><td>Bức xạ tia x</td><td>- Trình bày được đơn vị đo bức xạ<br>tia X, thực hiện đúng quy định kỹ<br>thuật an toàn bức xạ tia X.<br>- Mô tả chính xác phương pháp đo<br>bức xạ tia X.</td><td>12</td><td>02</td><td>10</td><td></td></tr><tr><td>02</td><td>Nguyên lý tạo hình<br>ảnh trên dsa và an toàn<br>bức xạ tia x</td><td>- Mô tả được nguyên lý tạo hình<br>ảnh trên DSA.<br>- Vận dụng được các nguyên tắc<br>an toàn bức xạ tia X.</td><td>22</td><td>02</td><td>20</td><td></td></tr><tr><td>03</td><td>Thuốc cản quang trong<br>can thiệp nội mạch</td><td>- Phân biệt được các nhóm thuốc<br>cản quang hiện nay.<br>- Áp dụng đúng liều lượng thuốc<br>cản quang trong can thiệp nội<br>mạch, các phản ứng bất lợi của<br>thuốc cản quang và xử trí.</td><td>22</td><td>02</td><td>20</td><td></td></tr><tr><td>04</td><td>Bệnh thận do thuốc<br>cản quang</td><td>- Trình bày tiêu chuẩn chẩn<br>đoán của bệnh thận do thuốc cản<br>quang (CIN)<br>- Giải thích chính xác cơ chế<br>bệnh sinh của CIN<br>Đánh giá được nguy cơ và xây dựng<br>cách phòng ngừa CIN.</td><td>42</td><td>02</td><td>20</td><td>20</td></tr><tr><td>05</td><td>Hình ảnh Giải phẫu<br>học của gan</td><td>- Trình bày được giải phẩu các<br>phân thùy gan, các cấu trúc cố<br>định gan và liên quan của gan với<br>các tạng xung quanh.</td><td>24</td><td>04</td><td>20</td><td></td></tr></tbody></table>
Binary file not shown.
@@ -1,16 +1,12 @@
package com.sisvietnamvn.web.config;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.repository.PostRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.Optional;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
@Component
public class PostMigrator implements CommandLineRunner {
@@ -23,57 +19,110 @@ public class PostMigrator implements CommandLineRunner {
@Override
public void run(String... args) throws Exception {
String[] slugs = {
"can-thiep-mach-mau-than-kinh-nang-cao",
"can-thiep-mach-mau-cac-tang-va-ngoai-bien-co-ban",
"chuan-bi-dung-cu-va-cham-soc-benh-nhan-trong-phong-chup-mach",
"tim-mach-can-thiep-co-ban"
};
System.out.println("=== STARTING JSON TO HTML MIGRATION ===");
String[] titles = {
"Can thiệp mạch máu thần kinh nâng cao",
"Can thiệp mạch máu các tạng và mạch máu ngoại biên cơ bản",
"Chuẩn bị dụng cụ và chăm sóc bệnh nhân trong phòng chụp mạch",
"Tim mạch can thiệp cơ bản"
};
List<Post> posts = postRepository.findAll();
ObjectMapper mapper = new ObjectMapper();
int count = 0;
System.out.println("=== STARTING MIGRATION FOR 4 COURSES ===");
for (int i = 0; i < slugs.length; i++) {
String slug = slugs[i];
String title = titles[i];
String path = "pdf_json_output/" + slug + ".json";
if (!Files.exists(Paths.get(path))) {
System.out.println("File not found: " + path);
continue;
for (Post post : posts) {
String content = post.getContent();
if (content != null && content.trim().startsWith("{") && content.contains("\"blocks\"")) {
System.out.println("Migrating post: " + post.getTitle());
String html = convertEditorJsToHtml(content, mapper);
post.setContent(html);
postRepository.save(post);
count++;
}
String json = new String(Files.readAllBytes(Paths.get(path)));
Optional<Post> opt = postRepository.findBySlug(slug);
Post post;
if (opt.isPresent()) {
post = opt.get();
System.out.println("Updating post: " + post.getTitle());
} else {
System.out.println("Creating new post: " + title);
post = new Post();
post.setSlug(slug);
post.setTitle(title);
post.setStatus(PageStatus.PUBLISHED);
post.setCreatedDate(Instant.now());
post.setLastModifiedDate(Instant.now());
post.setCreatedBy("admin");
post.setLastModifiedBy("admin");
post.setExcerpt("Khóa học " + title + " tại Bệnh viện Đa khoa Quốc tế S.I.S Cần Thơ.");
post.setEventTime(Instant.now().plus(30 + i * 10, ChronoUnit.DAYS)); // dummy start date
}
post.setContent(json);
postRepository.save(post);
}
System.out.println("=== SUCCESSFULLY MIGRATED 4 COURSES ===");
System.out.println("=== SUCCESSFULLY MIGRATED " + count + " POSTS TO HTML ===");
}
private String convertEditorJsToHtml(String json, ObjectMapper mapper) {
try {
JsonNode root = mapper.readTree(json);
JsonNode blocks = root.get("blocks");
if (blocks == null || !blocks.isArray()) return json;
StringBuilder html = new StringBuilder();
for (JsonNode block : blocks) {
String type = block.get("type").asText();
JsonNode data = block.get("data");
switch(type) {
case "header":
int level = data.has("level") ? data.get("level").asInt() : 2;
String text = data.has("text") ? data.get("text").asText() : "";
html.append("<h").append(level).append(">").append(text).append("</h").append(level).append(">\n");
break;
case "paragraph":
String ptext = data.has("text") ? data.get("text").asText() : "";
html.append("<p>").append(ptext).append("</p>\n");
break;
case "image":
String url = data.has("file") && data.get("file").has("url") ? data.get("file").get("url").asText() :
(data.has("url") ? data.get("url").asText() : "");
String caption = data.has("caption") ? data.get("caption").asText() : "";
html.append("<figure><img src='").append(url).append("' alt='").append(caption).append("'><figcaption>").append(caption).append("</figcaption></figure>\n");
break;
case "list":
String style = data.has("style") ? data.get("style").asText() : "unordered";
String tag = style.equals("ordered") ? "ol" : "ul";
html.append("<").append(tag).append(">\n");
JsonNode items = data.get("items");
if (items != null && items.isArray()) {
for (JsonNode item : items) {
html.append("<li>").append(item.asText()).append("</li>\n");
}
}
html.append("</").append(tag).append(">\n");
break;
case "quote":
String qtext = data.has("text") ? data.get("text").asText() : "";
String qcaption = data.has("caption") ? data.get("caption").asText() : "";
html.append("<blockquote>").append(qtext).append(" <cite>").append(qcaption).append("</cite></blockquote>\n");
break;
case "table":
html.append("<figure class='table' style='width:100%'><table>\n");
JsonNode content = data.get("content");
boolean withHeadings = data.has("withHeadings") && data.get("withHeadings").asBoolean();
if (content != null && content.isArray()) {
for (int i = 0; i < content.size(); i++) {
JsonNode row = content.get(i);
html.append("<tr>");
if (row.isArray()) {
for (JsonNode cell : row) {
String cellTag = (withHeadings && i == 0) ? "th" : "td";
html.append("<").append(cellTag).append(">").append(cell.asText()).append("</").append(cellTag).append(">");
}
}
html.append("</tr>\n");
}
}
html.append("</table></figure>\n");
break;
case "raw":
String rtext = data.has("html") ? data.get("html").asText() : "";
html.append(rtext).append("\n");
break;
case "tabStart":
String ttitle = data.has("title") ? data.get("title").asText() : "Tab";
// Use a special class so frontend can parse it back into tabs if needed
html.append("<h3 class='dynamic-tab-start'>").append(ttitle).append("</h3>\n");
break;
case "tabEnd":
html.append("<hr class='dynamic-tab-end'/>\n");
break;
default:
break;
}
}
return html.toString();
} catch (Exception e) {
e.printStackTrace();
return json; // Fallback
}
}
}
@@ -34,6 +34,8 @@
--spacing-md: 20px;
--radius-md: 4px;
--radius-lg: 8px;
--spacing: 0.25rem;
}
/* ==========================================================================
@@ -1083,7 +1085,6 @@ i, .btn-outline-primary {
display: flex;
flex-direction: column;
justify-content: center;
margin-bottom: 2rem;
width: 100%;
}
@@ -1095,6 +1096,7 @@ i, .btn-outline-primary {
gap: 4px; /* gap-x-1 */
width: 100%;
max-width: 860px;
margin-bottom:calc(var(--spacing) * 8);
}
@media (max-width: 768px) {
@@ -1125,8 +1127,88 @@ i, .btn-outline-primary {
background-color: transparent;
}
.custom-tab-btn.active {
/* .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);
} */
@media (min-width: 1280px) {
.xl\:mb-8 {
margin-bottom: calc(var(--spacing) * 8);
}
}
@media (min-width: 768px) {
.md\:mb-6 {
margin-bottom: calc(var(--spacing) * 6);
}
}
.mb-4 {
margin-bottom: calc(var(--spacing) * 4);
}
/* Editor.js Table Styling */
figure.table {
width: 100%;
overflow-x: auto;
margin: 2rem 0;
}
figure.table table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.05);
background-color: #ffffff;
border: 1px solid #e0e0e0;
}
figure.table table th,
figure.table table td {
padding: 12px 15px;
border: 1px solid #e0e0e0;
vertical-align: middle;
}
/* Biến dòng đầu tiên thành Header luôn cho đẹp nếu người dùng quên bật Header trong Editor.js */
figure.table table tr:first-child td,
figure.table table th {
background-color: var(--color-old-brick, #881c1c) !important;
color: #ffffff !important;
font-weight: 700;
text-align: center;
}
figure.table table tr {
border-bottom: 1px solid #e0e0e0;
}
figure.table table tr:nth-of-type(even) {
background-color: #fcfcfc;
}
figure.table table tr:hover {
background-color: #f5f5f5;
}
.training-layout table tr:first-child td,
.training-layout table th {
background-color: var(--color-old-brick, #881c1c) !important;
color: #ffffff !important;
font-weight: 700;
text-align: center;
}
.training-layout table td,
.training-layout table th {
border: 1px solid #dee2e6;
padding: 8px 12px;
}
.training-sidebar {
a {
border: white 1px solid;
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<!-- Widget hiển thị thông tin động của khóa học (Ngày khai giảng, địa điểm, hình thức...) -->
<div th:fragment="course-info" class="bg-primary-600 rounded-lg p-4 flex flex-col justify-center mb-4">
<div class="xl:space-y-8 md:space-y-6 space-y-4">
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-white rounded-full flex items-center justify-center shrink-0">
<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-calendar size-3.5 text-primary-600"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
</div>
<div>
<p class="text-white/80 text-sm">Ngày khai giảng</p>
<p class="text-white font-semibold" th:text="${post.eventTime != null ? #temporals.format(post.eventTime, 'dd/MM/yyyy') : 'Đang cập nhật'}">Đang cập nhật</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-white rounded-full flex items-center justify-center shrink-0">
<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-map-pin size-3.5 text-primary-600"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>
</div>
<div>
<p class="text-white/80 text-sm">Địa điểm</p>
<p class="text-white font-semibold" th:text="${post.location != null ? post.location : 'SIS Cần Thơ'}">SIS Cần Thơ</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-white rounded-full flex items-center justify-center shrink-0">
<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-users size-3.5 text-primary-600"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
</div>
<div>
<p class="text-white/80 text-sm">Hình thức</p>
<p class="text-white font-semibold">Trực tiếp / Tập trung</p>
</div>
</div>
<div class="pt-4 mt-4 border-t border-white/20">
<a href="#" class="block w-full py-3 px-4 bg-white text-primary-600 text-center font-bold rounded-lg hover:bg-gray-100 transition-colors">
Đăng ký ngay
</a>
</div>
</div>
</div>
</body>
</html>
@@ -5,67 +5,17 @@
<head>
<title th:text="${isNew} ? 'Add New Post' : 'Edit Post'">Post Form</title>
<style>
/* Editor.js container styling */
#editorjs {
border: 1px solid #d1d3e2;
/* TinyMCE Editor styling */
.tox-tinymce {
border-radius: 0.35rem;
padding: 16px 12px;
min-height: 350px;
background: #fff;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
border: 1px solid #d1d3e2;
}
#editorjs:focus-within {
.tox:focus-within {
border-color: #bac8f3;
box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25);
}
/* Block tool styling overrides */
.ce-block__content {
max-width: 100% !important;
}
/* Auto scale media inside snippets to fit editor */
.ce-snippet-wrapper img,
.ce-snippet-wrapper video,
.ce-snippet-wrapper iframe {
max-width: 100% !important;
height: auto !important;
}
.ce-toolbar__content {
max-width: 100% !important;
}
.codex-editor__redactor {
padding-bottom: 80px !important;
}
.editor-plugin-info {
font-size: 0.75rem;
color: #858796;
}
.editor-plugin-info .badge {
font-weight: 400;
font-size: 0.7rem;
}
/* Full Screen Editor Mode */
.editor-fullscreen {
position: fixed !important;
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
z-index: 9999 !important;
margin: 0 !important;
border-radius: 0 !important;
border: none !important;
overflow-y: auto !important;
padding: 40px !important;
}
/* Tag input styling */
.tag-input-container {
position: relative;
@@ -166,7 +116,10 @@
<div class="mt-2">
<small class="text-muted">
<i class="fas fa-link"></i> Permalink:
<code>/post/<span id="slugPreview" th:text="${post.slug ?: 'auto-generated'}"></span></code>
<a th:href="@{'/post/' + ${post.slug}}" target="_blank" style="text-decoration:none" title="Mở trang bài viết trong tab mới">
<code>/post/<span id="slugPreview" th:text="${post.slug ?: 'auto-generated'}"></span></code>
<i class="fas fa-external-link-alt" style="font-size:11px;margin-left:4px"></i>
</a>
</small>
<input type="hidden" id="postSlug" th:field="*{slug}">
</div>
@@ -184,27 +137,11 @@
</button>
</div>
<div class="card-body">
<div class="editor-plugin-info mb-2">
Active blocks:
<span class="badge badge-light">Heading</span>
<span class="badge badge-light">List</span>
<span class="badge badge-light">Quote</span>
<span class="badge badge-light">Table</span>
<span class="badge badge-light">Code</span>
<span class="badge badge-light">Delimiter</span>
<span class="badge badge-light">Warning</span>
<span id="pluginBadges"></span>
</div>
<!-- The Editor.js container -->
<div id="editorjs"></div>
<!-- Hidden input to store the JSON content for form submission -->
<input type="hidden" id="editorContent" name="content" th:value="*{content}">
<!-- TinyMCE Editor Container -->
<textarea id="editorContent" name="content" th:text="*{content}" style="height: 600px; width: 100%;"></textarea>
<small class="form-text text-muted mt-2">
<i class="fas fa-info-circle"></i>
Click the <strong>+</strong> button or press <kbd>Tab</kbd> to add new blocks.
Use the toolbar to insert tables, images, headings, and more.
</small>
</div>
</div>
@@ -366,92 +303,252 @@
</form>
</div>
<!-- Editor.js Scripts (injected via layout:fragment="scripts") -->
<!-- TinyMCE Script (injected via layout:fragment="scripts") -->
<section layout:fragment="scripts">
<!-- Editor.js Core -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.30.8/dist/editorjs.umd.js"></script>
<!-- TinyMCE Core (via CDN) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.8.3/tinymce.min.js" referrerpolicy="origin"></script>
<!-- Built-in Block Tools -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/header@2.8.8/dist/header.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/nested-list@1.4.3/dist/nested-list.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/quote@2.7.4/dist/quote.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/delimiter@1.4.2/dist/delimiter.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/table@2.4.2/dist/table.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/code@2.9.3/dist/code.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/warning@1.4.1/dist/warning.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/marker@1.4.0/dist/marker.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/inline-code@1.5.1/dist/inline-code.umd.js"></script>
<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>
<!-- Initialize the editor and page-specific scripts -->
<!-- Initialize TinyMCE -->
<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 };
}
tinymce.init({
selector: '#editorContent',
height: 600,
menubar: true,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'help', 'wordcount'
],
toolbar: 'code | undo redo | blocks | ' +
'bold italic backcolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'table tablemergecells tablesplitcells | ' +
'insertTabStart insertTabEnd editClassId | previewTabs | removeformat | help',
toolbar_mode: 'wrap',
content_style: `
body { font-family:Helvetica,Arial,sans-serif; font-size:16px; }
.dynamic-tab-start {
display: inline-block;
background: #881c1c;
color: #fff !important;
padding: 8px 20px;
border-radius: 6px 6px 0 0;
margin: 24px 0 0 0;
font-size: 15px;
font-weight: 600;
border: 2px solid #881c1c;
border-bottom: none;
position: relative;
}
.dynamic-tab-start::before {
content: '📑 TAB: ';
font-size: 11px;
opacity: 0.8;
}
.dynamic-tab-start + p, .dynamic-tab-start + div, .dynamic-tab-start + table, .dynamic-tab-start + figure {
border-left: 3px solid #881c1c;
padding-left: 12px;
margin-left: 4px;
}
hr.dynamic-tab-end {
border: none;
border-top: 3px dashed #881c1c;
margin: 24px 0;
position: relative;
}
hr.dynamic-tab-end::after {
content: '— KẾT THÚC NHÓM TAB —';
display: block;
text-align: center;
color: #881c1c;
font-size: 11px;
font-weight: 600;
margin-top: 4px;
}
`,
// Convert URLs to absolute if needed, or keep relative
convert_urls: false,
// Table specific settings to make tables responsive and nice
table_default_attributes: {
border: '1'
},
table_default_styles: {
'border-collapse': 'collapse',
'width': '100%'
},
table_responsive_width: true,
// --- Editor.js Initialization ---
var existingContent = document.getElementById('editorContent').value;
var initialData = null;
if (existingContent && existingContent.trim() !== '') {
try {
initialData = JSON.parse(existingContent);
} catch (e) {
console.warn('[SIS Editor] Existing content is not valid JSON, treating as Raw HTML.');
initialData = {
time: Date.now(),
blocks: [
{
type: "raw",
data: {
html: existingContent
}
setup: function (editor) {
editor.on('change', function () {
tinymce.triggerSave();
});
editor.ui.registry.addButton('insertTabStart', {
text: 'Thêm Tab',
tooltip: 'Tạo một Tab ngang mới (Dành cho Chi tiết khoá học)',
onAction: function (_) {
var tabName = prompt('Nhập tiêu đề Tab (ví dụ: Tổng quan):', 'Tiêu đề Tab');
if (tabName) {
editor.insertContent('<h3 class="dynamic-tab-start">' + tabName + '</h3><p>Nhập nội dung của Tab vào đây...</p>');
}
]
};
}
});
editor.ui.registry.addButton('insertTabEnd', {
text: 'Kết thúc Tab',
tooltip: 'Đóng/Kết thúc nhóm Tab hiện tại',
onAction: function (_) {
editor.insertContent('<p>&nbsp;</p><hr class="dynamic-tab-end"/><p>&nbsp;</p>');
}
});
editor.ui.registry.addButton('previewTabs', {
text: '👁 Xem trước',
tooltip: 'Xem trước bài viết với Tab hoạt động giống giao diện thực',
onAction: function (_) {
var content = editor.getContent();
// Parse tab markers into real tab UI
var parser = document.createElement('div');
parser.innerHTML = content;
var tabStarts = parser.querySelectorAll('h3.dynamic-tab-start');
if (tabStarts.length === 0) {
// No tabs, just show raw content
editor.windowManager.open({
title: 'Xem trước bài viết',
size: 'large',
body: {
type: 'panel',
items: [{
type: 'htmlpanel',
html: '<div style="padding:16px;font-family:Helvetica,Arial,sans-serif;max-height:500px;overflow-y:auto">' + content + '</div>'
}]
},
buttons: [{ type: 'cancel', text: 'Đóng' }]
});
return;
}
// Build tab UI
var tabs = [];
tabStarts.forEach(function(startEl) {
var tabTitle = startEl.textContent;
var contentParts = [];
var currentEl = startEl.nextElementSibling;
while (currentEl && !currentEl.classList.contains('dynamic-tab-start') && !currentEl.classList.contains('dynamic-tab-end')) {
contentParts.push(currentEl.outerHTML);
currentEl = currentEl.nextElementSibling;
}
tabs.push({ title: tabTitle, html: contentParts.join('') });
});
// Build preview HTML
var previewHtml = '<div style="font-family:Helvetica,Arial,sans-serif;max-height:520px;overflow-y:auto">';
previewHtml += '<div style="display:flex;border:1px solid #881c1c;border-radius:8px;overflow:hidden;margin-bottom:16px">';
tabs.forEach(function(tab, i) {
var bg = (i === 0) ? 'background:#881c1c;color:#fff;' : 'background:#fff;color:#881c1c;';
previewHtml += '<button onclick="(function(btn){var wrap=btn.closest(\'.tce-preview-wrap\');wrap.querySelectorAll(\'.tce-tab-btn\').forEach(function(b){b.style.background=\'#fff\';b.style.color=\'#881c1c\';});btn.style.background=\'#881c1c\';btn.style.color=\'#fff\';wrap.querySelectorAll(\'.tce-tab-panel\').forEach(function(p){p.style.display=\'none\';});wrap.querySelector(\'.tce-tab-panel[data-idx=\\\'' + i + '\\\']\').style.display=\'block\';})(this)" class="tce-tab-btn" style="' + bg + 'padding:10px 20px;border:none;cursor:pointer;font-weight:600;font-size:14px;flex:1">' + tab.title + '</button>';
});
previewHtml += '</div>';
tabs.forEach(function(tab, i) {
previewHtml += '<div class="tce-tab-panel" data-idx="' + i + '" style="display:' + (i === 0 ? 'block' : 'none') + ';padding:12px">' + tab.html + '</div>';
});
previewHtml += '</div>';
editor.windowManager.open({
title: 'Xem trước Tab',
size: 'large',
body: {
type: 'panel',
items: [{
type: 'htmlpanel',
html: '<div class="tce-preview-wrap">' + previewHtml + '</div>'
}]
},
buttons: [{ type: 'cancel', text: 'Đóng' }]
});
}
});
editor.ui.registry.addButton('editClassId', {
text: '{ } Thuộc tính',
tooltip: 'Gắn ID và Class cho phần tử đang chọn',
onAction: function (_) {
var node = editor.selection.getNode();
if (!node || node.nodeName === 'BODY') {
alert('Vui lòng chọn hoặc bôi đen một đối tượng (đoạn văn, hình ảnh, bảng,...) trước khi gắn ID/Class!');
return;
}
var currentId = node.id || '';
var currentClass = node.className || '';
editor.windowManager.open({
title: 'Khai báo ID & Class',
body: {
type: 'panel',
items: [
{
type: 'htmlpanel',
html: '<p style="color:#666">Phần tử đang chọn: <b>&lt;' + node.nodeName.toLowerCase() + '&gt;</b></p>'
},
{
type: 'input',
name: 'id',
label: 'Thuộc tính ID (viết liền, không dấu)'
},
{
type: 'input',
name: 'className',
label: 'CSS Class (các class cách nhau bằng khoảng trắng)'
}
]
},
buttons: [
{
type: 'cancel',
text: 'Hủy'
},
{
type: 'submit',
text: 'Lưu',
primary: true
}
],
initialData: {
id: currentId,
className: currentClass
},
onSubmit: function (api) {
var data = api.getData();
if (data.id.trim() !== '') {
node.id = data.id.trim();
} else {
node.removeAttribute('id');
}
if (data.className.trim() !== '') {
node.className = data.className.trim();
} else {
node.removeAttribute('class');
}
// Update content manually just in case
editor.setDirty(true);
api.close();
}
});
}
});
}
}
});
window.sisEditor = initSISEditor('editorjs', 'editorContent', initialData);
// Show badges for any injected plugins
var pluginBadges = document.getElementById('pluginBadges');
var pluginKeys = Object.keys(window.SISEditorPlugins || {});
if (pluginBadges && pluginKeys.length > 0) {
pluginKeys.forEach(function (key) {
var badge = document.createElement('span');
badge.className = 'badge badge-info ml-1';
badge.textContent = key + ' (plugin)';
pluginBadges.appendChild(badge);
});
// Remove the initial JSON warning for TinyMCE
var existingContent = document.getElementById('editorContent').value;
if (existingContent && existingContent.trim().startsWith('{') && existingContent.includes('"blocks"')) {
console.warn('[SIS Editor] Found raw JSON in editor area. This post might not have been migrated correctly!');
}
// --- Unsaved Changes Warning ---
@@ -468,25 +565,11 @@
});
// --- Full Screen Mode Toggle ---
var editorContainer = document.getElementById('editorjs');
// Fullscreen is handled natively by TinyMCE via the toolbar (fullscreen plugin)
var fsBtn = document.getElementById('toggleFullscreenBtn');
var fsIcon = fsBtn.querySelector('i');
fsBtn.addEventListener('click', function () {
editorContainer.classList.toggle('editor-fullscreen');
if (editorContainer.classList.contains('editor-fullscreen')) {
fsIcon.classList.remove('fa-expand');
fsIcon.classList.add('fa-compress');
fsBtn.style.position = 'fixed';
fsBtn.style.top = '10px';
fsBtn.style.right = '20px';
fsBtn.style.zIndex = '10000';
} else {
fsIcon.classList.remove('fa-compress');
fsIcon.classList.add('fa-expand');
fsBtn.style.position = 'static';
}
});
if (fsBtn) {
fsBtn.style.display = 'none';
}
// --- Featured Image Preview ---
var imgInput = document.getElementById('postFeaturedImage');
@@ -39,6 +39,7 @@
<select class="form-control" id="type" name="type">
<option value="TEXT">Text</option>
<option value="HTML">Custom HTML</option>
<option value="COURSE_INFO">Thông tin Khóa học (Tự động)</option>
</select>
</div>
<div class="form-group">
@@ -101,163 +101,82 @@
</div>
</div>
<!-- Include Editor.js parser to render the content on the frontend -->
<!-- Content rendering and dynamic tabs parsing -->
<script>
document.addEventListener("DOMContentLoaded", function() {
var rawData = document.getElementById("postContentRaw").textContent;
var parsedContainer = document.getElementById("postContentParsed");
try {
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
// Content is now HTML
parsedContainer.innerHTML = rawData;
// Parse dynamic tabs from HTML (migrated from Editor.js)
var tabStarts = Array.from(parsedContainer.querySelectorAll('h3.dynamic-tab-start'));
if (tabStarts.length > 0) {
var tabsGroup = [];
var wrapper = document.createElement('div');
wrapper.className = 'custom-tabs-wrapper';
var navContainer = document.createElement('div');
navContainer.className = 'custom-tabs-nav-container';
var tabsContainer = document.createElement('div');
tabsContainer.className = 'tabs-container';
navContainer.appendChild(tabsContainer);
wrapper.appendChild(navContainer);
var firstTabStart = tabStarts[0];
var parentNode = firstTabStart.parentNode;
// Collect all elements into tabs
tabStarts.forEach(function(startEl, index) {
var tabTitle = startEl.textContent;
var btn = document.createElement('button');
btn.className = 'dao-tao-tab-btn custom-tab-btn' + (index === 0 ? ' active' : '');
btn.setAttribute('data-tab-idx', index);
btn.textContent = tabTitle;
tabsContainer.appendChild(btn);
var inTabs = false;
var tabsGroup = null;
function renderSingleBlock(block) {
var html = "";
switch(block.type) {
case "paragraph":
html += "<p>" + block.data.text + "</p>";
break;
case "header":
html += "<h" + block.data.level + ">" + block.data.text + "</h" + block.data.level + ">";
break;
case "list":
var tag = block.data.style === "ordered" ? "ol" : "ul";
html += "<" + tag + ">";
block.data.items.forEach(function(item) {
html += "<li>" + item + "</li>";
});
html += "</" + tag + ">";
break;
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>';
var panel = document.createElement('div');
panel.className = 'dao-tao-tab-panel custom-tab-panel';
panel.setAttribute('data-tab-idx', index);
panel.style.display = (index === 0) ? 'block' : 'none';
var currentEl = startEl.nextElementSibling;
while (currentEl && !currentEl.classList.contains('dynamic-tab-start') && !currentEl.classList.contains('dynamic-tab-end')) {
var nextEl = currentEl.nextElementSibling;
panel.appendChild(currentEl);
currentEl = nextEl;
}
// Remove the dynamic-tab-end if present
if (currentEl && currentEl.classList.contains('dynamic-tab-end')) {
currentEl.parentNode.removeChild(currentEl);
}
wrapper.appendChild(panel);
startEl.parentNode.removeChild(startEl);
});
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>';
parentNode.appendChild(wrapper);
// Tab click listener
wrapper.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var idx = btn.getAttribute('data-tab-idx');
wrapper.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
wrapper.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = wrapper.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
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;
}
});
parsedContainer.innerHTML = html;
} else {
parsedContainer.innerHTML = rawData.replace(/\n/g, "<br>");
}
} catch(e) {
// Fallback to raw text if it is not JSON
parsedContainer.innerHTML = rawData.replace(/\n/g, "<br>");
}
});
</script>
@@ -167,175 +167,83 @@
</div>
</div>
<!-- Include Editor.js parser to render the content on the frontend -->
<!-- Because Editor.js saves as JSON, we need to convert it to HTML. -->
<!-- Content rendering and dynamic tabs parsing -->
<script>
document.addEventListener("DOMContentLoaded", function() {
var rawData = document.getElementById("postContentRaw").textContent;
var parsedContainer = document.getElementById("postContentParsed");
try {
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
// Content is now HTML
parsedContainer.innerHTML = rawData;
// Parse dynamic tabs from HTML (migrated from Editor.js)
var tabStarts = Array.from(parsedContainer.querySelectorAll('h3.dynamic-tab-start'));
if (tabStarts.length > 0) {
var tabsGroup = [];
var wrapper = document.createElement('div');
wrapper.className = 'custom-tabs-wrapper';
var navContainer = document.createElement('div');
navContainer.className = 'custom-tabs-nav-container';
var tabsContainer = document.createElement('div');
tabsContainer.className = 'tabs-container';
navContainer.appendChild(tabsContainer);
wrapper.appendChild(navContainer);
var firstTabStart = tabStarts[0];
var parentNode = firstTabStart.parentNode;
// Collect all elements into tabs
tabStarts.forEach(function(startEl, index) {
var tabTitle = startEl.textContent;
var btn = document.createElement('button');
btn.className = 'dao-tao-tab-btn custom-tab-btn' + (index === 0 ? ' active' : '');
btn.setAttribute('data-tab-idx', index);
btn.textContent = tabTitle;
tabsContainer.appendChild(btn);
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;
case "paragraph":
html += "<p>" + block.data.text + "</p>";
break;
case "image":
html += "<figure><img src='" + block.data.url + "' alt='" + (block.data.caption || '') + "'><figcaption>" + (block.data.caption || '') + "</figcaption></figure>";
break;
case "list":
var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
html += "<" + tag + ">";
block.data.items.forEach(function(item) {
html += "<li>" + item + "</li>";
});
html += "</" + tag + ">";
break;
case "quote":
html += "<blockquote>" + block.data.text + " <cite>" + block.data.caption + "</cite></blockquote>";
break;
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>';
var panel = document.createElement('div');
panel.className = 'dao-tao-tab-panel custom-tab-panel';
panel.setAttribute('data-tab-idx', index);
panel.style.display = (index === 0) ? 'block' : 'none';
var currentEl = startEl.nextElementSibling;
while (currentEl && !currentEl.classList.contains('dynamic-tab-start') && !currentEl.classList.contains('dynamic-tab-end')) {
var nextEl = currentEl.nextElementSibling;
panel.appendChild(currentEl);
currentEl = nextEl;
}
// Remove the dynamic-tab-end if present
if (currentEl && currentEl.classList.contains('dynamic-tab-end')) {
currentEl.parentNode.removeChild(currentEl);
}
wrapper.appendChild(panel);
startEl.parentNode.removeChild(startEl);
});
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>';
parentNode.appendChild(wrapper);
// Tab click listener
wrapper.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var idx = btn.getAttribute('data-tab-idx');
wrapper.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
wrapper.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = wrapper.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
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) {
// If it's not JSON, it might just be HTML
parsedContainer.innerHTML = rawData;
}
// Fix for long paragraphs mistakenly formatted as headings with bold tags
var headings = parsedContainer.querySelectorAll('h1, h2, h3, h4, h5, h6');
headings.forEach(function(h) {
if (h.textContent.length > 100) {
var p = document.createElement('p');
p.innerHTML = h.innerHTML;
var strongs = p.querySelectorAll('strong, b');
strongs.forEach(function(s) {
var frag = document.createDocumentFragment();
while(s.firstChild) frag.appendChild(s.firstChild);
s.parentNode.replaceChild(frag, s);
});
h.parentNode.replaceChild(p, h);
}
});
});
</script>
</div>