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> <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>
.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>
Binary file not shown.
@@ -1,16 +1,12 @@
package com.sisvietnamvn.web.config; package com.sisvietnamvn.web.config;
import com.sisvietnamvn.web.domain.Post; import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.repository.PostRepository; import com.sisvietnamvn.web.repository.PostRepository;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Optional; import java.util.List;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
@Component @Component
public class PostMigrator implements CommandLineRunner { public class PostMigrator implements CommandLineRunner {
@@ -23,57 +19,110 @@ public class PostMigrator implements CommandLineRunner {
@Override @Override
public void run(String... args) throws Exception { public void run(String... args) throws Exception {
String[] slugs = { System.out.println("=== STARTING JSON TO HTML MIGRATION ===");
"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"
};
String[] titles = { List<Post> posts = postRepository.findAll();
"Can thiệp mạch máu thần kinh nâng cao", ObjectMapper mapper = new ObjectMapper();
"Can thiệp mạch máu các tạng và mạch máu ngoại biên cơ bản", int count = 0;
"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"
};
System.out.println("=== STARTING MIGRATION FOR 4 COURSES ==="); for (Post post : posts) {
String content = post.getContent();
for (int i = 0; i < slugs.length; i++) { if (content != null && content.trim().startsWith("{") && content.contains("\"blocks\"")) {
String slug = slugs[i]; System.out.println("Migrating post: " + post.getTitle());
String title = titles[i]; String html = convertEditorJsToHtml(content, mapper);
String path = "pdf_json_output/" + slug + ".json"; post.setContent(html);
postRepository.save(post);
if (!Files.exists(Paths.get(path))) { count++;
System.out.println("File not found: " + path);
continue;
} }
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; --spacing-md: 20px;
--radius-md: 4px; --radius-md: 4px;
--radius-lg: 8px; --radius-lg: 8px;
--spacing: 0.25rem;
} }
/* ========================================================================== /* ==========================================================================
@@ -1083,7 +1085,6 @@ i, .btn-outline-primary {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
margin-bottom: 2rem;
width: 100%; width: 100%;
} }
@@ -1095,6 +1096,7 @@ i, .btn-outline-primary {
gap: 4px; /* gap-x-1 */ gap: 4px; /* gap-x-1 */
width: 100%; width: 100%;
max-width: 860px; max-width: 860px;
margin-bottom:calc(var(--spacing) * 8);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -1125,8 +1127,88 @@ i, .btn-outline-primary {
background-color: transparent; background-color: transparent;
} }
.custom-tab-btn.active { /* .custom-tab-btn.active {
background-color: white !important; background-color: white !important;
color: var(--color-old-brick) !important; /* text-primary-600 */ color: var(--color-old-brick) !important; /* text-primary-600 */
box-shadow: 0 1px 3px rgba(0,0,0,0.1); 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> <head>
<title th:text="${isNew} ? 'Add New Post' : 'Edit Post'">Post Form</title> <title th:text="${isNew} ? 'Add New Post' : 'Edit Post'">Post Form</title>
<style> <style>
/* Editor.js container styling */ /* TinyMCE Editor styling */
#editorjs { .tox-tinymce {
border: 1px solid #d1d3e2;
border-radius: 0.35rem; border-radius: 0.35rem;
padding: 16px 12px; border: 1px solid #d1d3e2;
min-height: 350px;
background: #fff;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
} }
#editorjs:focus-within { .tox:focus-within {
border-color: #bac8f3; border-color: #bac8f3;
box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25); 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 styling */
.tag-input-container { .tag-input-container {
position: relative; position: relative;
@@ -166,7 +116,10 @@
<div class="mt-2"> <div class="mt-2">
<small class="text-muted"> <small class="text-muted">
<i class="fas fa-link"></i> Permalink: <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> </small>
<input type="hidden" id="postSlug" th:field="*{slug}"> <input type="hidden" id="postSlug" th:field="*{slug}">
</div> </div>
@@ -184,27 +137,11 @@
</button> </button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="editor-plugin-info mb-2"> <!-- TinyMCE Editor Container -->
Active blocks: <textarea id="editorContent" name="content" th:text="*{content}" style="height: 600px; width: 100%;"></textarea>
<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}">
<small class="form-text text-muted mt-2"> <small class="form-text text-muted mt-2">
<i class="fas fa-info-circle"></i> <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> </small>
</div> </div>
</div> </div>
@@ -366,92 +303,252 @@
</form> </form>
</div> </div>
<!-- Editor.js Scripts (injected via layout:fragment="scripts") --> <!-- TinyMCE Script (injected via layout:fragment="scripts") -->
<section layout:fragment="scripts"> <section layout:fragment="scripts">
<!-- Editor.js Core --> <!-- TinyMCE Core (via CDN) -->
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.30.8/dist/editorjs.umd.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.8.3/tinymce.min.js" referrerpolicy="origin"></script>
<!-- Built-in Block Tools --> <!-- Initialize TinyMCE -->
<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 -->
<script th:inline="javascript"> <script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
// Register Plugins tinymce.init({
window.SISEditorPlugins = window.SISEditorPlugins || {}; selector: '#editorContent',
if (typeof RawTool !== 'undefined') { height: 600,
window.SISEditorPlugins['raw'] = { class: RawTool }; menubar: true,
} plugins: [
if (typeof TabSplitTool !== 'undefined') { 'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
window.SISEditorPlugins['tabSplit'] = { class: TabSplitTool }; 'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
} 'insertdatetime', 'media', 'table', 'help', 'wordcount'
],
if (typeof TrainingCourseInfoTool !== 'undefined') { toolbar: 'code | undo redo | blocks | ' +
} 'bold italic backcolor | alignleft aligncenter ' +
if (typeof TabStartTool !== 'undefined') { 'alignright alignjustify | bullist numlist outdent indent | ' +
window.SISEditorPlugins['tabStart'] = { class: TabStartTool }; 'table tablemergecells tablesplitcells | ' +
} 'insertTabStart insertTabEnd editClassId | previewTabs | removeformat | help',
if (typeof TabEndTool !== 'undefined') { toolbar_mode: 'wrap',
window.SISEditorPlugins['tabEnd'] = { class: TabEndTool }; 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 --- setup: function (editor) {
var existingContent = document.getElementById('editorContent').value; editor.on('change', function () {
var initialData = null; tinymce.triggerSave();
});
if (existingContent && existingContent.trim() !== '') {
try { editor.ui.registry.addButton('insertTabStart', {
initialData = JSON.parse(existingContent); text: 'Thêm Tab',
} catch (e) { tooltip: 'Tạo một Tab ngang mới (Dành cho Chi tiết khoá học)',
console.warn('[SIS Editor] Existing content is not valid JSON, treating as Raw HTML.'); onAction: function (_) {
initialData = { var tabName = prompt('Nhập tiêu đề Tab (ví dụ: Tổng quan):', 'Tiêu đề Tab');
time: Date.now(), if (tabName) {
blocks: [ editor.insertContent('<h3 class="dynamic-tab-start">' + tabName + '</h3><p>Nhập nội dung của Tab vào đây...</p>');
{
type: "raw",
data: {
html: existingContent
}
} }
] }
}; });
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); // Remove the initial JSON warning for TinyMCE
var existingContent = document.getElementById('editorContent').value;
// Show badges for any injected plugins if (existingContent && existingContent.trim().startsWith('{') && existingContent.includes('"blocks"')) {
var pluginBadges = document.getElementById('pluginBadges'); console.warn('[SIS Editor] Found raw JSON in editor area. This post might not have been migrated correctly!');
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);
});
} }
// --- Unsaved Changes Warning --- // --- Unsaved Changes Warning ---
@@ -468,25 +565,11 @@
}); });
// --- Full Screen Mode Toggle --- // --- 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 fsBtn = document.getElementById('toggleFullscreenBtn');
var fsIcon = fsBtn.querySelector('i'); if (fsBtn) {
fsBtn.style.display = 'none';
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';
}
});
// --- Featured Image Preview --- // --- Featured Image Preview ---
var imgInput = document.getElementById('postFeaturedImage'); var imgInput = document.getElementById('postFeaturedImage');
@@ -39,6 +39,7 @@
<select class="form-control" id="type" name="type"> <select class="form-control" id="type" name="type">
<option value="TEXT">Text</option> <option value="TEXT">Text</option>
<option value="HTML">Custom HTML</option> <option value="HTML">Custom HTML</option>
<option value="COURSE_INFO">Thông tin Khóa học (Tự động)</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -101,163 +101,82 @@
</div> </div>
</div> </div>
<!-- Include Editor.js parser to render the content on the frontend --> <!-- Content rendering and dynamic tabs parsing -->
<script> <script>
document.addEventListener("DOMContentLoaded", function() { document.addEventListener("DOMContentLoaded", function() {
var rawData = document.getElementById("postContentRaw").textContent; var rawData = document.getElementById("postContentRaw").textContent;
var parsedContainer = document.getElementById("postContentParsed"); var parsedContainer = document.getElementById("postContentParsed");
try { // Content is now HTML
var data = JSON.parse(rawData); parsedContainer.innerHTML = rawData;
var html = "";
if (data.blocks) { // 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 panel = document.createElement('div');
var tabsGroup = null; panel.className = 'dao-tao-tab-panel custom-tab-panel';
panel.setAttribute('data-tab-idx', index);
function renderSingleBlock(block) { panel.style.display = (index === 0) ? 'block' : 'none';
var html = "";
switch(block.type) { var currentEl = startEl.nextElementSibling;
while (currentEl && !currentEl.classList.contains('dynamic-tab-start') && !currentEl.classList.contains('dynamic-tab-end')) {
case "paragraph": var nextEl = currentEl.nextElementSibling;
html += "<p>" + block.data.text + "</p>"; panel.appendChild(currentEl);
break; currentEl = nextEl;
case "header": }
html += "<h" + block.data.level + ">" + block.data.text + "</h" + block.data.level + ">";
break; // Remove the dynamic-tab-end if present
case "list": if (currentEl && currentEl.classList.contains('dynamic-tab-end')) {
var tag = block.data.style === "ordered" ? "ol" : "ul"; currentEl.parentNode.removeChild(currentEl);
html += "<" + tag + ">"; }
block.data.items.forEach(function(item) {
html += "<li>" + item + "</li>"; wrapper.appendChild(panel);
}); startEl.parentNode.removeChild(startEl);
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>';
}); });
groupHtml += '</div>';
parentNode.appendChild(wrapper);
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none'; // Tab click listener
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">'; wrapper.addEventListener('click', function(e) {
groupHtml += tab.contentHtml; if (e.target.classList.contains('custom-tab-btn')) {
groupHtml += '</div>'; 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> </script>
@@ -167,175 +167,83 @@
</div> </div>
</div> </div>
<!-- Include Editor.js parser to render the content on the frontend --> <!-- Content rendering and dynamic tabs parsing -->
<!-- Because Editor.js saves as JSON, we need to convert it to HTML. -->
<script> <script>
document.addEventListener("DOMContentLoaded", function() { document.addEventListener("DOMContentLoaded", function() {
var rawData = document.getElementById("postContentRaw").textContent; var rawData = document.getElementById("postContentRaw").textContent;
var parsedContainer = document.getElementById("postContentParsed"); var parsedContainer = document.getElementById("postContentParsed");
try {
var data = JSON.parse(rawData); // Content is now HTML
var html = ""; parsedContainer.innerHTML = rawData;
if (data.blocks) {
// 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 panel = document.createElement('div');
var tabsGroup = null; panel.className = 'dao-tao-tab-panel custom-tab-panel';
panel.setAttribute('data-tab-idx', index);
function renderSingleBlock(block) { panel.style.display = (index === 0) ? 'block' : 'none';
var html = "";
switch(block.type) { var currentEl = startEl.nextElementSibling;
while (currentEl && !currentEl.classList.contains('dynamic-tab-start') && !currentEl.classList.contains('dynamic-tab-end')) {
case "header": var nextEl = currentEl.nextElementSibling;
html += "<h" + block.data.level + ">" + block.data.text + "</h" + block.data.level + ">"; panel.appendChild(currentEl);
break; currentEl = nextEl;
case "paragraph": }
html += "<p>" + block.data.text + "</p>";
break; // Remove the dynamic-tab-end if present
case "image": if (currentEl && currentEl.classList.contains('dynamic-tab-end')) {
html += "<figure><img src='" + block.data.url + "' alt='" + (block.data.caption || '') + "'><figcaption>" + (block.data.caption || '') + "</figcaption></figure>"; currentEl.parentNode.removeChild(currentEl);
break; }
case "list":
var tag = block.data.style === 'ordered' ? 'ol' : 'ul'; wrapper.appendChild(panel);
html += "<" + tag + ">"; startEl.parentNode.removeChild(startEl);
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>';
}); });
groupHtml += '</div>';
parentNode.appendChild(wrapper);
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none'; // Tab click listener
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">'; wrapper.addEventListener('click', function(e) {
groupHtml += tab.contentHtml; if (e.target.classList.contains('custom-tab-btn')) {
groupHtml += '</div>'; 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> </script>
</div> </div>