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

This commit is contained in:
2026-07-27 17:00:43 +07:00
parent 54e420499f
commit 1a8726285c
98 changed files with 8041 additions and 3949 deletions
@@ -0,0 +1,110 @@
package com.sisvietnamvn.web;
import com.sisvietnamvn.web.domain.Category;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.PostLayout;
import com.sisvietnamvn.web.repository.CategoryRepository;
import com.sisvietnamvn.web.repository.PostRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.Optional;
@Component
public class NoiSoiCourseSeeder implements CommandLineRunner {
private static final Logger LOG = LoggerFactory.getLogger(NoiSoiCourseSeeder.class);
private final CategoryRepository categoryRepository;
private final PostRepository postRepository;
public NoiSoiCourseSeeder(CategoryRepository categoryRepository, PostRepository postRepository) {
this.categoryRepository = categoryRepository;
this.postRepository = postRepository;
}
@Override
public void run(String... args) throws Exception {
LOG.info("=================================================================");
LOG.info("NOI SOI COURSE SEEDER: RUNNING AUTOMATIC DATA SEEDING...");
LOG.info("=================================================================");
try {
// 1. Find or create the Category 'dao-tao'
String categorySlug = "dao-tao";
Category category = categoryRepository.findBySlug(categorySlug).orElseGet(() -> {
Category cat = new Category();
// Generate id dynamically
Long nextId = categoryRepository.count() + 1;
cat.setId(nextId);
cat.setName("Đào tạo");
cat.setSlug(categorySlug);
cat.setDescription("Chương trình đào tạo y khoa");
cat.setCreatedBy("system");
cat.setCreatedDate(Instant.now());
cat = categoryRepository.save(cat);
LOG.info("Created new Category 'dao-tao' with ID: {}", cat.getId());
return cat;
});
// 2. Read content from parsed files
String contentPath = "/tmp/course_content.html";
String excerptPath = "/tmp/course_excerpt.html";
if (!Files.exists(Paths.get(contentPath)) || !Files.exists(Paths.get(excerptPath))) {
LOG.warn("Seed content files do not exist at /tmp. Skipping seeder.");
return;
}
String content = Files.readString(Paths.get(contentPath));
String excerpt = Files.readString(Paths.get(excerptPath));
// 3. Find or create the Post (slug: test-dao-tao)
String postSlug = "test-dao-tao";
Optional<Post> postOpt = postRepository.findBySlug(postSlug);
Post post;
if (postOpt.isPresent()) {
post = postOpt.get();
LOG.info("Found existing Post with slug 'test-dao-tao', updating fields...");
} else {
post = new Post();
// Generate id dynamically
Long nextId = postRepository.count() + 1000L;
post.setId(nextId);
post.setSlug(postSlug);
post.setCreatedBy("system");
post.setCreatedDate(Instant.now());
LOG.info("Creating new Post with slug 'test-dao-tao' (ID: {})", post.getId());
}
post.setTitle("CHƯƠNG TRÌNH CHỨNG CHỈ ĐÀO TẠO KỸ THUẬT CHUYÊN MÔN: NỘI SOI DẠ DÀY, NỘI SOI ĐẠI TRÀNG VÀ NỘI SOI ĐIỀU TRỊ CƠ BẢN, KHÓA 02");
post.setContent(content + "\n<!-- TAB_SPLIT -->\n" + excerpt);
post.setExcerpt("Xem chi tiết thông tin khóa học ở tab bên cạnh");
post.setLocation("Giảng đường 3A, lầu 3, khu A, Bệnh viện Đại học Y Dược Thành phố Hồ Chí Minh (215 Hồng Bàng, Phường Chợ Lớn, Thành phố Hồ Chí Minh)");
post.setMetaDescription("38.000.000 VNĐ");
post.setEventTime(Instant.parse("2026-07-27T07:30:00Z"));
post.setStatus(PageStatus.PUBLISHED);
post.setLayout(PostLayout.TRAINING);
post.setCategory(category);
post.setFeaturedImage("https://console.bvdaihoc.com.vn/uploads/KHDT/T%E1%BB%95%20S%E1%BB%B1%20Ki%E1%BB%87n%202026/07/N%E1%BB%98I-SOI-D%E1%BA%A0-D%C3%80Y,-N%E1%BB%98I-SOI-%C4%90%E1%BA%A0I-TR%C3%80NG-V%C3%80-N%E1%BB%98I-SOI-%C4%90I%E1%BB%80U-TR%E1%BB%8A-C%C6%A0-B%E1%BA%A2N-(1).gif");
postRepository.save(post);
LOG.info("=================================================================");
LOG.info("NOI SOI COURSE SEEDED SUCCESSFULLY!");
LOG.info("URL: http://localhost:8080/post/test-dao-tao");
LOG.info("=================================================================");
} catch (Exception e) {
LOG.error("Failed to seed Noi Soi course data: ", e);
}
}
}
@@ -0,0 +1,79 @@
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 com.fasterxml.jackson.databind.ObjectMapper;
@Component
public class PostMigrator implements CommandLineRunner {
private final PostRepository postRepository;
public PostMigrator(PostRepository postRepository) {
this.postRepository = postRepository;
}
@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"
};
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"
};
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;
}
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 ===");
}
}
@@ -23,6 +23,9 @@ import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.security.SecurityUtils;
import com.sisvietnamvn.web.service.PageService;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.repository.PostRepository;
/**
* Controller for rendering public-facing pages dynamically.
@@ -41,12 +44,14 @@ public class PageController {
private final com.sisvietnamvn.web.service.DoctorApiSyncService doctorApiSyncService;
private final com.sisvietnamvn.web.service.SettingService settingService;
private final com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository;
private final PostRepository postRepository;
public PageController(PageService pageService, ObjectMapper objectMapper, HookManager hookManager, com.sisvietnamvn.web.service.HtmlSnippetService snippetService,
com.sisvietnamvn.web.service.DoctorService doctorService, com.sisvietnamvn.web.service.SpecialtyService specialtyService,
com.sisvietnamvn.web.service.DoctorApiSyncService doctorApiSyncService,
com.sisvietnamvn.web.service.SettingService settingService,
com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository) {
com.sisvietnamvn.web.repository.DoctorScheduleRepository doctorScheduleRepository,
PostRepository postRepository) {
this.pageService = pageService;
this.objectMapper = objectMapper;
this.hookManager = hookManager;
@@ -56,6 +61,7 @@ public class PageController {
this.doctorApiSyncService = doctorApiSyncService;
this.settingService = settingService;
this.doctorScheduleRepository = doctorScheduleRepository;
this.postRepository = postRepository;
}
@GetMapping("/page/{slug}")
@@ -81,10 +87,48 @@ public class PageController {
public String getAboutUs(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.ABOUT_US), model); }
@GetMapping("/dao-tao")
@org.springframework.transaction.annotation.Transactional(readOnly = true)
public String getDaoTao(Model model) {
java.util.List<Post> daoTaoPosts = postRepository.findAll().stream()
.filter(p -> p.getStatus() == PageStatus.PUBLISHED)
.filter(p -> (p.getCategory() != null && "dao-tao".equalsIgnoreCase(p.getCategory().getSlug())) ||
java.util.Arrays.asList("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").contains(p.getSlug()))
.sorted((p1, p2) -> {
if (p1.getCreatedDate() == null && p2.getCreatedDate() == null) return 0;
if (p1.getCreatedDate() == null) return 1;
if (p2.getCreatedDate() == null) return -1;
return p2.getCreatedDate().compareTo(p1.getCreatedDate());
})
.collect(java.util.stream.Collectors.toList());
model.addAttribute("daoTaoPosts", daoTaoPosts);
return "dao-tao";
}
@GetMapping("/dao-tao/{slug}")
public String getDaoTaoDetail(@PathVariable("slug") String slug, Model model) {
Post post = postRepository.findBySlug(slug)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (post.getStatus() != PageStatus.PUBLISHED) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
// Apply WordPress-style Content Filters
post.setTitle(hookManager.applyFilters("the_title", post.getTitle()));
post.setContent(hookManager.applyFilters("the_content", post.getContent()));
// Format date for display
if (post.getCreatedDate() != null) {
String formattedDate = java.time.format.DateTimeFormatter.ofPattern("dd/MM/yyyy")
.withZone(java.time.ZoneId.systemDefault())
.format(post.getCreatedDate());
model.addAttribute("formattedDate", formattedDate);
}
model.addAttribute("post", post);
return "dao-tao-detail";
}
@GetMapping("/specialty")
public String getSpecialty(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SPECIALTY), model); }
@@ -4,6 +4,10 @@ import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.repository.PostRepository;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.service.HtmlSnippetService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -11,17 +15,27 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.List;
@Controller
@RequestMapping("/post")
public class PostController {
private static final Logger LOG = LoggerFactory.getLogger(PostController.class);
private final PostRepository postRepository;
private final HookManager hookManager;
private final ObjectMapper objectMapper;
private final HtmlSnippetService snippetService;
public PostController(PostRepository postRepository, HookManager hookManager) {
public PostController(PostRepository postRepository, HookManager hookManager, ObjectMapper objectMapper, HtmlSnippetService snippetService) {
this.postRepository = postRepository;
this.hookManager = hookManager;
this.objectMapper = objectMapper;
this.snippetService = snippetService;
}
@GetMapping("/{slug}")
@@ -36,7 +50,30 @@ public class PostController {
// Apply WordPress-style Content Filters
post.setTitle(hookManager.applyFilters("the_title", post.getTitle()));
post.setContent(hookManager.applyFilters("the_content", post.getContent()));
String content = hookManager.applyFilters("the_content", post.getContent());
if (content != null && !content.trim().isEmpty()) {
try {
Map<String, Object> editorData = objectMapper.readValue(content, new TypeReference<>() {});
if (editorData.containsKey("blocks")) {
List<Map<String, Object>> blocks = (List<Map<String, Object>>) editorData.get("blocks");
for (Map<String, Object> block : blocks) {
if ("snippet".equals(block.get("type"))) {
Map<String, Object> data = (Map<String, Object>) block.get("data");
if (data != null && data.containsKey("id")) {
String snippetId = (String) data.get("id");
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
}
}
}
content = objectMapper.writeValueAsString(editorData);
}
} catch (JsonProcessingException e) {
LOG.error("Failed to parse Editor.js JSON for post ID: {}", post.getId(), e);
}
}
post.setContent(content);
if (post.getExcerpt() != null) {
post.setExcerpt(hookManager.applyFilters("the_excerpt", post.getExcerpt()));
}
@@ -51,16 +88,29 @@ public class PostController {
model.addAttribute("formattedDate", formattedDate);
}
switch (post.getLayout()) {
case SIDEBAR:
return "posts/sidebar";
case FULL_WIDTH:
return "posts/full-width";
case LIVESTREAM:
return "posts/livestream";
case STANDARD:
default:
return "posts/standard";
// Determine which template to use based on layout (with null-safe handling)
com.sisvietnamvn.web.domain.PostLayout layout = post.getLayout();
// Fallback: if no layout is set, check category to auto-detect training posts
if (layout == null && post.getCategory() != null && "dao-tao".equalsIgnoreCase(post.getCategory().getSlug())) {
layout = com.sisvietnamvn.web.domain.PostLayout.TRAINING;
}
if (layout != null) {
switch (layout) {
case SIDEBAR:
return "posts/sidebar";
case FULL_WIDTH:
return "posts/full-width";
case LIVESTREAM:
return "posts/livestream";
case TRAINING:
return "dao-tao-detail";
case STANDARD:
default:
return "posts/standard";
}
}
return "posts/standard";
}
}
@@ -0,0 +1,26 @@
package com.sisvietnamvn.web.controller;
import com.sisvietnamvn.web.NoiSoiCourseSeeder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SeederController {
private final NoiSoiCourseSeeder seeder;
public SeederController(NoiSoiCourseSeeder seeder) {
this.seeder = seeder;
}
@GetMapping("/api/seed-dao-tao")
public String seed() {
try {
seeder.run();
return "Seeding completed successfully! Check the console logs for details.";
} catch (Exception e) {
e.printStackTrace();
return "Seeding failed: " + e.getMessage();
}
}
}
@@ -8,5 +8,6 @@ public enum PostLayout {
SIDEBAR,
FULL_WIDTH,
EVENT,
LIVESTREAM
LIVESTREAM,
TRAINING
}
@@ -3,7 +3,9 @@ package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.Media;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.PostLayout;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.repository.CategoryRepository;
import com.sisvietnamvn.web.repository.MediaRepository;
import com.sisvietnamvn.web.repository.PageRepository;
import com.sisvietnamvn.web.repository.PostRepository;
@@ -39,11 +41,13 @@ public class ImportExportService {
private final PostRepository postRepository;
private final PageRepository pageRepository;
private final MediaRepository mediaRepository;
private final CategoryRepository categoryRepository;
public ImportExportService(PostRepository postRepository, PageRepository pageRepository, MediaRepository mediaRepository) {
public ImportExportService(PostRepository postRepository, PageRepository pageRepository, MediaRepository mediaRepository, CategoryRepository categoryRepository) {
this.postRepository = postRepository;
this.pageRepository = pageRepository;
this.mediaRepository = mediaRepository;
this.categoryRepository = categoryRepository;
}
/**
@@ -249,6 +253,34 @@ public class ImportExportService {
}
}
// Parse categories
NodeList catNodes = el.getElementsByTagName("category");
for (int j = 0; j < catNodes.getLength(); j++) {
Node catNode = catNodes.item(j);
if (catNode.getNodeType() == Node.ELEMENT_NODE) {
Element catEl = (Element) catNode;
String domain = catEl.getAttribute("domain");
if ("category".equals(domain)) {
String catSlug = catEl.getAttribute("nicename");
String catName = catEl.getTextContent();
if (catSlug != null && !catSlug.isEmpty()) {
com.sisvietnamvn.web.domain.Category category = categoryRepository.findBySlug(catSlug).orElseGet(() -> {
com.sisvietnamvn.web.domain.Category newCat = new com.sisvietnamvn.web.domain.Category();
newCat.setSlug(catSlug);
newCat.setName(catName);
return categoryRepository.save(newCat);
});
post.setCategory(category);
// Auto-assign TRAINING layout for dao-tao category
if ("dao-tao".equalsIgnoreCase(catSlug)) {
post.setLayout(PostLayout.TRAINING);
}
break; // just use the first category found
}
}
}
}
postRepository.save(post);
}
} else if ("page".equalsIgnoreCase(postType)) {
@@ -90,15 +90,19 @@ body, h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
}
.cc--tabbed-media-content .tab-labels-inner {
border-bottom-color: #cccccc !important; /* light gray line for the tab row */
border-bottom-color: #cccccc !important;
/* light gray line for the tab row */
}
.cc--tabbed-media-content .tab-label {
color: #555555 !important; /* dark gray for inactive tabs */
color: #555555 !important;
/* dark gray for inactive tabs */
font-weight: 700 !important;
}
.cc--tabbed-media-content .tab-label[aria-selected="true"] {
color: #000000 !important; /* solid black for active tab */
border-bottom: 3px solid #000000 !important; /* solid black underline for active tab */
color: #000000 !important;
/* solid black for active tab */
border-bottom: 3px solid #000000 !important;
/* solid black underline for active tab */
}
.cc--tabbed-media-content .tabbed-media-content-media-col {
@@ -572,7 +576,8 @@ body, h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
#map-section .branch-text {
color: #111827; /* text-gray-900 */
color: #111827;
/* text-gray-900 */
}
#map-section .branch-card {
@@ -581,12 +586,14 @@ body, h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
/* Active State */
#map-section .branch-card.active {
background-color: var(--color-old-brick); /* bg-primary-600 */
background-color: var(--color-old-brick);
/* bg-primary-600 */
}
#map-section .branch-card.active .branch-title,
#map-section .branch-card.active .branch-text,
#map-section .branch-card.active .branch-icon {
color: #ffffff; /* text-white */
color: #ffffff;
/* text-white */
}
/* Hero Background Override */
@@ -644,4 +651,482 @@ i, .btn-outline-primary {
.bg-linear-service {
background: linear-gradient(#60a7d700, var(--color-old-brick)) !important;
}
}
/* Training Detail Layout */
.training-section {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.training-layout {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.training-main {
flex: 1 1 100%;
min-width: 0;
}
.training-sidebar {
flex: 1 1 100%;
}
.training-sticky {
position: static;
}
@media (min-width: 768px) {
.training-section {
padding-top: 2rem;
padding-bottom: 2rem;
}
.training-layout {
gap: 1.5rem;
}
}
@media (min-width: 1024px) {
.training-container {
padding-left: 2.5rem;
padding-right: 2.5rem;
}
.training-layout {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.training-main {
grid-column: span 9 / span 9;
}
.training-sidebar {
grid-column: span 3 / span 3;
}
.training-sticky {
position: sticky;
top: 132px;
}
}
@media (min-width: 1280px) {
.training-section {
padding-top: 3rem;
padding-bottom: 3rem;
}
.training-layout {
gap: 2rem;
}
}
.education .responsive-flex-container {
max-width: 1280px;
margin: auto;
}
.education .bg-primary-600.rounded-lg.p-4.flex.flex-col.justify-center {
animation-composition: replace;
animation-delay: 0s;
animation-duration: 0s;
animation-iteration-count: 1;
animation-play-state: running;
animation-timing-function: ease;
backface-visibility: visible;
background-attachment: scroll;
background-clip: border-box;
background-color: rgb(13, 113, 186);
background-origin: padding-box;
background-position: 0% 0%;
background-repeat: repeat;
baseline-shift: 0px;
block-size: 388.609px;
border-block-end-color: rgb(0, 0, 0);
border-block-end-style: solid;
border-block-end-width: 0px;
border-block-start-color: rgb(0, 0, 0);
border-block-start-style: solid;
border-block-start-width: 0px;
border-bottom-color: rgb(0, 0, 0);
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
border-bottom-style: solid;
border-bottom-width: 0px;
border-collapse: separate;
border-end-end-radius: 8px;
border-end-start-radius: 8px;
border-image-outset: 0;
border-image-repeat: stretch;
border-image-slice: 100%;
border-image-width: 1;
border-inline-end-color: rgb(0, 0, 0);
border-inline-end-style: solid;
border-inline-end-width: 0px;
border-inline-start-color: rgb(0, 0, 0);
border-inline-start-style: solid;
border-inline-start-width: 0px;
border-left-color: rgb(0, 0, 0);
border-left-style: solid;
border-left-width: 0px;
border-right-color: rgb(0, 0, 0);
border-right-style: solid;
border-right-width: 0px;
border-start-end-radius: 8px;
border-start-start-radius: 8px;
border-top-color: rgb(0, 0, 0);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-top-style: solid;
border-top-width: 0px;
box-decoration-break: slice;
box-sizing: border-box;
caption-side: top;
caret-color: rgb(0, 0, 0);
clip-rule: nonzero;
color: rgb(0, 0, 0);
color-interpolation: srgb;
color-interpolation-filters: linearrgb;
column-fill: balance;
column-rule-color: rgb(0, 0, 0);
column-rule-inset-cap-end: 0px;
column-rule-inset-cap-start: 0px;
column-rule-inset-junction-end: 0px;
column-rule-inset-junction-start: 0px;
column-rule-width: 3px;
content-visibility: visible;
corner-bottom-left-shape: round;
corner-bottom-right-shape: round;
corner-end-end-shape: round;
corner-end-start-shape: round;
corner-start-end-shape: round;
corner-start-start-shape: round;
corner-top-left-shape: round;
corner-top-right-shape: round;
cx: 0px;
cy: 0px;
direction: ltr;
display: flex;
dynamic-range-limit: no-limit;
empty-cells: show;
field-sizing: fixed;
fill: rgb(0, 0, 0);
fill-opacity: 1;
fill-rule: nonzero;
flex-direction: column;
flex-grow: 0;
flex-line-count: 1;
flex-shrink: 1;
flex-wrap: nowrap;
flood-color: rgb(0, 0, 0);
flood-opacity: 1;
font-family: ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;
font-size: 16px;
font-stretch: 100%;
font-weight: 400;
grid-auto-flow: row;
height: 388.609px;
hyphens: manual;
image-orientation: from-image;
inline-size: 276px;
inset-block-start: 132px;
interpolate-size: numeric-only;
justify-content: center;
lighting-color: rgb(255, 255, 255);
line-height: 24px;
list-style-position: outside;
list-style-type: disc;
margin-block-end: 0px;
margin-block-start: 0px;
margin-bottom: 0px;
margin-inline-end: 0px;
margin-inline-start: 0px;
margin-left: 0px;
margin-right: 0px;
margin-top: 0px;
mask-clip: border-box;
mask-composite: add;
mask-mode: match-source;
mask-origin: border-box;
mask-position: 0% 0%;
mask-repeat: repeat;
mask-type: luminance;
math-depth: 0;
min-block-size: 0px;
min-height: 0px;
min-inline-size: 0px;
min-width: 0px;
object-fit: fill;
object-position: 50% 50%;
offset-distance: 0px;
offset-rotate: auto 0deg;
opacity: 1;
order: 0;
orphans: 2;
outline-color: rgb(0, 0, 0);
outline-offset: 0px;
outline-width: 3px;
overflow-block: visible;
overflow-clip-margin: 0px;
overflow-inline: visible;
overflow-x: visible;
overflow-y: visible;
padding-block-end: 16px;
padding-block-start: 16px;
padding-bottom: 16px;
padding-inline-end: 16px;
padding-inline-start: 16px;
padding-left: 16px;
padding-right: 16px;
padding-top: 16px;
perspective-origin: 138px 194.297px;
position: sticky;
position-visibility: anchors-visible;
print-color-adjust: economy;
r: 0px;
reading-order: 0;
row-rule-color: rgb(0, 0, 0);
row-rule-inset-cap-end: 0px;
row-rule-inset-cap-start: 0px;
row-rule-inset-junction-end: 0px;
row-rule-inset-junction-start: 0px;
row-rule-width: 3px;
ruby-align: space-around;
ruby-position: over;
rule-overlap: row-over-column;
scroll-margin-block-end: 0px;
scroll-margin-block-start: 0px;
scroll-margin-bottom: 0px;
scroll-margin-inline-end: 0px;
scroll-margin-inline-start: 0px;
scroll-margin-left: 0px;
scroll-margin-right: 0px;
scroll-margin-top: 0px;
scroll-timeline-axis: block;
shape-image-threshold: 0;
shape-margin: 0px;
stop-color: rgb(0, 0, 0);
stop-opacity: 1;
stroke-dashoffset: 0px;
stroke-linecap: butt;
stroke-linejoin: miter;
stroke-miterlimit: 4;
stroke-opacity: 1;
stroke-width: 1px;
tab-size: 4;
text-align: start;
text-anchor: start;
text-autospace: no-autospace;
text-decoration-color: rgb(0, 0, 0);
text-decoration-style: solid;
text-emphasis-color: rgb(0, 0, 0);
text-emphasis-position: over;
text-indent: 0px;
text-orientation: mixed;
text-overflow: clip;
text-size-adjust: 100%;
text-wrap-mode: wrap;
top: 132px;
transform-box: view-box;
transform-origin: 138px 194.305px;
transform-style: flat;
transition-delay: 0s;
transition-duration: 0s;
transition-property: all;
transition-timing-function: ease;
unicode-bidi: isolate;
vertical-align: baseline;
view-timeline-axis: block;
visibility: visible;
white-space-collapse: collapse;
widows: 2;
width: 276px;
word-spacing: 0px;
writing-mode: horizontal-tb;
x: 0px;
y: 0px;
zoom: 1;
/* -webkit-border-horizontal-spacing: 0px;
-webkit-border-vertical-spacing: 0px;
-webkit-box-align: stretch;
-webkit-box-decoration-break: slice;
-webkit-box-flex: 0;
-webkit-box-ordinal-group: 1;
-webkit-box-orient: horizontal;
-webkit-box-pack: start;
-webkit-locale: &quot;vi&quot;;
-webkit-mask-box-image-outset: 0;
-webkit-mask-box-image-repeat: stretch;
-webkit-mask-box-image-slice: 0 fill;
-webkit-mask-position-x: 0%;
-webkit-mask-position-y: 0%;
-webkit-rtl-ordering: logical;
-webkit-ruby-position: before;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-text-fill-color: rgb(0, 0, 0);
-webkit-text-orientation: vertical-right;
-webkit-text-stroke-color: rgb(0, 0, 0);
-webkit-text-stroke-width: 0px;
-webkit-user-modify: read-only;
-webkit-writing-mode: horizontal-tb;
--container-md: 28rem;
--tw-ring-offset-shadow: 0 0 #0000;
--color-blue-100: oklch(93.2% .032 255.585);
--tw-inset-shadow-alpha: 100%;
--tw-ring-shadow: 0 0 #0000;
--text-2xl: 1.5rem;
--aspect-video: 16/9;
--color-green-600: oklch(62.7% .194 149.214);
--leading-relaxed: 1.625;
--blur-lg: 16px;
--color-sky-500: oklch(68.5% .169 237.323);
--text-9xl: 8rem;
--tw-drop-shadow-alpha: 100%;
--font-weight-black: 900;
--text-lg: 1.125rem;
--color-neutral-600: oklch(43.9% 0 0);
--default-transition-timing-function: cubic-bezier(.4,0,.2,1);
--color-blue-700: oklch(48.8% .243 264.376);
--color-green-50: oklch(98.2% .018 155.826);
--tw-outline-style: solid;
--text-2xl--line-height: calc(2/1.5);
--color-green-100: oklch(96.2% .044 156.743);
--tw-inset-ring-shadow: 0 0 #0000;
--text-base--line-height: calc(1.5/1);
--spacing: .25rem;
--tw-border-spacing-x: 0px;
--color-red-200: oklch(88.5% .062 18.334);
--color-neutral-200: oklch(92.2% 0 0);
--tw-translate-z: 0;
--tw-gradient-via: rgba(0, 0, 0, 0);
--tw-scale-y: 1;
--font-mono: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,&quot;Liberation Mono&quot;,&quot;Courier New&quot;,monospace;
--container-6xl: 72rem;
--text-xl--line-height: calc(1.75/1.25);
--color-green-800: oklch(44.8% .119 151.328);
--font-weight-semibold: 600;
--container-3xl: 48rem;
--color-red-700: oklch(50.5% .213 27.518);
--text-sm: .875rem;
--tw-translate-y: 0;
--ease-out: cubic-bezier(0,0,.2,1);
--swiper-theme-color: #007aff;
--color-blue-500: oklch(62.3% .214 259.815);
--color-green-500: oklch(72.3% .219 149.579);
--animate-spin: spin 1s linear infinite;
--container-4xl: 56rem;
--text-9xl--line-height: 1;
--tw-inset-shadow: 0 0 #0000;
--tw-ring-offset-color: #fff;
--text-lg--line-height: calc(1.75/1.125);
--tw-divide-x-reverse: 0;
--tw-gradient-from: rgba(0, 0, 0, 0);
--tw-content: &quot;&quot;;
--color-white: #fff;
--color-blue-400: oklch(70.7% .165 254.624);
--tw-shadow-alpha: 100%;
--tw-gradient-to: rgba(0, 0, 0, 0);
--leading-tight: 1.25;
--radius-sm: .25rem;
--color-blue-600: oklch(54.6% .245 262.881);
--font-sans: ui-sans-serif,system-ui,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;,&quot;Segoe UI Symbol&quot;,&quot;Noto Color Emoji&quot;;
--text-4xl: 2.25rem;
--color-green-200: oklch(92.5% .084 155.995);
--tw-scale-z: 1;
--tw-border-style: solid;
--font-weight-bold: 700;
--swiper-navigation-size: 44px;
--color-red-800: oklch(44.4% .177 26.899);
--color-red-600: oklch(57.7% .245 27.325);
--container-2xl: 42rem;
--color-red-50: oklch(97.1% .013 17.38);
--tw-shadow: 0 0 #0000;
--text-sm--line-height: calc(1.25/.875);
--tw-translate-x: 0;
--tw-ring-offset-width: 0px;
--container-sm: 24rem;
--color-blue-200: oklch(88.2% .059 254.128);
--color-blue-50: oklch(97% .014 254.604);
--color-red-500: oklch(63.7% .237 25.331);
--tw-gradient-via-position: 50%;
--text-3xl--line-height: calc(2.25/1.875);
--text-6xl: 3.75rem;
--text-3xl: 1.875rem;
--color-black: #000;
--text-xs--line-height: calc(1/.75);
--default-font-family: ui-sans-serif,system-ui,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;,&quot;Segoe UI Symbol&quot;,&quot;Noto Color Emoji&quot;;
--font-weight-light: 300;
--tw-space-x-reverse: 0;
--font-weight-normal: 400;
--tw-scale-x: 1;
--color-neutral-700: oklch(37.1% 0 0);
--text-xs: .75rem;
--font-weight-medium: 500;
--tw-gradient-to-position: 100%;
--tw-border-spacing-y: 0px;
--color-neutral-50: oklch(98.5% 0 0);
--default-transition-duration: .15s;
--radius-xl: .75rem;
--animate-pulse: pulse 2s cubic-bezier(.4,0,.6,1)infinite;
--radius-2xl: 1rem;
--tw-space-y-reverse: 0;
--text-6xl--line-height: 1;
--container-xl: 36rem;
--color-blue-800: oklch(42.4% .199 265.638);
--color-sky-700: oklch(50% .134 242.749);
--radius-lg: .5rem;
--text-4xl--line-height: calc(2.5/2.25);
--text-xl: 1.25rem;
--color-orange-300: oklch(83.7% .128 66.29);
--radius-md: .375rem;
--container-7xl: 80rem;
--text-base: 1rem;
--tw-gradient-from-position: 0px;
--default-mono-font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,&quot;Liberation Mono&quot;,&quot;Courier New&quot;,monospace;
--ease-in-out: cubic-bezier(.4,0,.2,1); */
}
/* CSS cho Tab Đa năng - Giao diện Mới (Segmented Control) */
.custom-tabs-nav-container {
display: flex;
flex-direction: column;
justify-content: center;
margin-bottom: 2rem;
width: 100%;
}
.tabs-container {
background-color: var(--color-old-brick); /* primary-600 */
border-radius: 8px; /* rounded-lg */
padding: 4px; /* p-1 */
display: flex;
gap: 4px; /* gap-x-1 */
width: 100%;
max-width: 860px;
}
@media (max-width: 768px) {
.tabs-container {
flex-direction: column;
width: 100%;
max-width: 100%;
}
}
.custom-tab-btn {
padding: 8px 16px; /* px-4 py-2 */
border: none;
background-color: transparent;
color: white;
cursor: pointer;
border-radius: 6px; /* rounded-md */
transition: all 0.2s ease-in-out;
font-weight: 600;
font-size: 16px;
flex: 1;
text-align: center;
min-width: 120px;
}
.custom-tab-btn:hover {
color: rgba(255, 255, 255, 0.8);
background-color: transparent;
}
.custom-tab-btn.active {
background-color: white !important;
color: var(--color-old-brick) !important; /* text-primary-600 */
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
@@ -37,10 +37,10 @@ window.SISEditorPlugins = window.SISEditorPlugins || {};
* @param {string} holderId - The DOM element ID for the editor container
* @param {string} hiddenInputId - The DOM element ID for the hidden input storing JSON
* @param {object|null} initialData - Pre-existing Editor.js JSON data to load
* @param {boolean} [skipSubmitHandler=false] - If true, prevents automatic form submission handling
* @returns {EditorJS} The editor instance
*/
function initSISEditor(holderId, hiddenInputId, initialData) {
'use strict';
function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler = false) {
// === Built-in Tools ===
var builtInTools = {
@@ -158,7 +158,7 @@ function initSISEditor(holderId, hiddenInputId, initialData) {
// === Form submission handler ===
// Ensure the latest content is saved before form submit
var form = document.querySelector('form');
if (form) {
if (form && !skipSubmitHandler) {
var submitHandler = function(event) {
event.preventDefault();
editor.save().then(function(outputData) {
@@ -0,0 +1,31 @@
class TabEndTool {
static get toolbox() {
return {
title: 'Kết thúc Tabs',
icon: '<svg viewBox="0 0 24 24" width="20" height="20"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>'
};
}
constructor({ data, api }) {
this.data = data;
this.api = api;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.style.padding = '10px';
this.wrapper.style.border = '2px dashed #dc3545';
this.wrapper.style.backgroundColor = '#fff3f3';
this.wrapper.style.margin = '20px 0';
this.wrapper.style.textAlign = 'center';
this.wrapper.style.fontWeight = 'bold';
this.wrapper.style.color = '#dc3545';
this.wrapper.innerHTML = '--- KẾT THÚC CỤM TABS ---';
return this.wrapper;
}
save(blockContent) {
return {};
}
}
@@ -0,0 +1,65 @@
/**
* Tab Split Block Plugin for Editor.js
*
* Allows users to insert a visual "Tab Split" marker in the editor.
* When saved, it outputs `{ type: 'tabSplit', data: {} }`.
* The frontend parser will use this block to split content into tabs.
*/
class TabSplitTool {
/**
* Notify Editor.js that this tool doesn't require any inline toolbar
*/
static get toolbox() {
return {
title: 'Tab Split',
icon: '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"/></svg>'
};
}
/**
* Allow rendering without data (when inserted from the toolbox)
*/
static get isReadOnlySupported() {
return true;
}
constructor({ data, config, api }) {
this.api = api;
this.data = data || {};
this.wrapper = undefined;
}
/**
* Render the UI for this block
* @returns {HTMLElement}
*/
render() {
this.wrapper = document.createElement('div');
this.wrapper.classList.add('ce-tab-split');
this.wrapper.style.textAlign = 'center';
this.wrapper.style.padding = '15px 0';
this.wrapper.style.margin = '10px 0';
this.wrapper.style.borderTop = '2px dashed #4e73df';
this.wrapper.style.borderBottom = '2px dashed #4e73df';
this.wrapper.style.backgroundColor = '#f8f9fc';
this.wrapper.style.color = '#4e73df';
this.wrapper.style.fontWeight = 'bold';
this.wrapper.style.fontSize = '14px';
this.wrapper.style.textTransform = 'uppercase';
this.wrapper.style.letterSpacing = '2px';
this.wrapper.style.userSelect = 'none';
this.wrapper.innerHTML = '<span><i class="fas fa-columns mr-2"></i> --- TAB SPLIT --- </span>';
return this.wrapper;
}
/**
* Save data from this block
* @param {HTMLElement} blockContent
* @returns {Object}
*/
save(blockContent) {
return {}; // We only care about the block type, no inner data needed
}
}
@@ -0,0 +1,50 @@
class TabStartTool {
static get toolbox() {
return {
title: 'Bắt đầu Tab',
icon: '<svg viewBox="0 0 24 24" width="20" height="20"><path d="M3 3h18v18H3V3zm16 16V7H5v12h14zM7 9h10v2H7V9z"/></svg>'
};
}
constructor({ data, api }) {
this.data = {
title: data.title || ''
};
this.api = api;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.style.padding = '15px';
this.wrapper.style.border = '2px dashed #007bff';
this.wrapper.style.backgroundColor = '#f8f9fa';
this.wrapper.style.margin = '20px 0';
this.wrapper.style.borderRadius = '5px';
const label = document.createElement('div');
label.innerHTML = '<span style="color:#007bff;font-weight:bold;margin-right:10px;">[ THẺ TAB MỚI ]</span> Nhập tên thẻ Tab (Ví dụ: Tổng quan, Lịch học, Học phí...):';
label.style.marginBottom = '10px';
const input = document.createElement('input');
input.type = 'text';
input.value = this.data.title;
input.placeholder = 'Tên Tab...';
input.classList.add('form-control');
input.addEventListener('input', (event) => {
this.data.title = event.target.value;
});
this.wrapper.appendChild(label);
this.wrapper.appendChild(input);
return this.wrapper;
}
save(blockContent) {
const input = blockContent.querySelector('input');
return {
title: input ? input.value : this.data.title
};
}
}
File diff suppressed because one or more lines are too long
@@ -109,6 +109,45 @@
</div>
</section>
<section class="xl:py-12 md:py-8 py-6 bg-[#f6f6f6] xl:space-y-12 md:space-y-8 space-y-6">
<div class="lg:container max-lg:pl-5 max-md:px-0" th:if="${daoTaoPosts != null and not #lists.isEmpty(daoTaoPosts)}">
<div class="flex justify-between items-end md:mb-6 mb-4 lg:mb-8 max-lg:pr-5 max-md:px-4">
<div>
<h2 class="display-7 text-primary-600">Khóa học Đào tạo</h2>
</div>
<div class="flex items-center gap-4">
<div class="flex md:gap-2 gap-1">
<button class="btn-navigation-khoa-hoc-prev md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group swiper-button-disabled swiper-button-lock" disabled="">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90">
<path d="m18 15-6-6-6 6"></path>
</svg>
</button>
<button class="btn-navigation-khoa-hoc-next md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group swiper-button-disabled swiper-button-lock" disabled="">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90">
<path d="m6 9 6 6 6-6"></path>
</svg>
</button>
</div>
</div>
</div>
<div class="relative max-md:px-4 md:mb-6 mb-4 lg:mb-8">
<div class="swiper swiper-khoa-hoc w-full">
<div class="swiper-wrapper">
<th:block th:each="post : ${daoTaoPosts}">
<div class="swiper-slide h-auto max-w-[288px] mr-2 lg:!mr-3 xl:!mr-4" style="width: 288px; max-width: 100%;">
<a th:href="@{/dao-tao/{slug}(slug=${post.slug})}" class="group h-full flex flex-col items-start gap-y-4 lg:gap-y-5 rounded-2xl bg-white p-4 pb-5 shadow-sm transition-shadow hover:shadow-md border border-transparent hover:border-primary-100">
<div class="relative w-full aspect-square overflow-hidden rounded-xl">
<img th:src="${post.imageUrl != null and !#strings.isEmpty(post.imageUrl) ? post.imageUrl : '/images/dao-tao/training-1.jpeg'}" th:alt="${post.title}" class="absolute inset-0 size-full object-cover transition-transform duration-500 group-hover:scale-105">
</div>
<div class="flex flex-1 flex-col justify-between w-full">
<h3 class="heading-5 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-3" th:text="${post.title}">Tiêu đề khóa học</h3>
</div>
</a>
</div>
</th:block>
</div>
</div>
</div>
</div>
<div class="lg:container max-lg:pl-5 max-md:px-0">
<div class="flex justify-between items-end md:mb-6 mb-4 lg:mb-8 max-lg:pr-5 max-md:px-4">
<div>
@@ -644,6 +683,11 @@
prev: '.btn-navigation-gallery-prev',
next: '.btn-navigation-gallery-next',
pagination: '.swiper-pagination-training-gallery'
}, {
slug: 'khoa-hoc',
prev: '.btn-navigation-khoa-hoc-prev',
next: '.btn-navigation-khoa-hoc-next',
pagination: ''
}, {
slug: 'chung-chi',
prev: '.btn-navigation-cap-chung-chi-prev',
@@ -268,9 +268,16 @@
<!-- Init Editor -->
<script th:src="@{/js/manage/editor-config.js}"></script>
<!-- Initialize the editor with existing content (if editing) -->
<!-- Initialize the editor and page-specific scripts -->
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
// Register RawTool for imported HTML content
window.SISEditorPlugins = window.SISEditorPlugins || {};
if (typeof RawTool !== 'undefined') {
window.SISEditorPlugins['raw'] = { class: RawTool };
}
// --- Editor.js Initialization ---
var existingContent = document.getElementById('editorContent').value;
var initialData = null;
@@ -278,7 +285,18 @@
try {
initialData = JSON.parse(existingContent);
} catch (e) {
console.warn('[SIS Editor] Existing content is not valid JSON, starting fresh.');
console.warn('[SIS Editor] Existing content is not valid JSON, treating as Raw HTML.');
initialData = {
time: Date.now(),
blocks: [
{
type: "raw",
data: {
html: existingContent
}
}
]
};
}
}
@@ -260,8 +260,10 @@
<option th:each="s : ${statuses}" th:value="${s}" th:text="${s}"></option>
</select>
</div>
<div class="form-group mt-3">
<label for="postLayout" class="font-weight-bold">Layout</label>
<div class="form-group mb-0">
<label for="postLayout" class="font-weight-bold">
<i class="fas fa-columns"></i> Layout
</label>
<select class="form-control" id="postLayout" th:field="*{layout}">
<option th:each="l : ${layouts}" th:value="${l}" th:text="${l}"></option>
</select>
@@ -382,9 +384,14 @@
<script src="https://cdn.jsdelivr.net/npm/@editorjs/underline@1.1.0/dist/bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/image@2.9.0/dist/image.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/attaches@1.3.0/dist/bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/raw@2.4.0/dist/bundle.js"></script>
<!-- Custom SIS Editor Plugins -->
<script th:src="@{/js/manage/editor-plugins/html-snippet.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tab-split.js}"></script>
<script th:src="@{/js/manage/editor-plugins/training-course.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tab-start.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tab-end.js}"></script>
<!-- Init Editor -->
<script th:src="@{/js/manage/editor-config.js}"></script>
@@ -392,6 +399,24 @@
<!-- Initialize the editor and page-specific scripts -->
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
// Register Plugins
window.SISEditorPlugins = window.SISEditorPlugins || {};
if (typeof RawTool !== 'undefined') {
window.SISEditorPlugins['raw'] = { class: RawTool };
}
if (typeof TabSplitTool !== 'undefined') {
window.SISEditorPlugins['tabSplit'] = { class: TabSplitTool };
}
if (typeof TrainingCourseInfoTool !== 'undefined') {
}
if (typeof TabStartTool !== 'undefined') {
window.SISEditorPlugins['tabStart'] = { class: TabStartTool };
}
if (typeof TabEndTool !== 'undefined') {
window.SISEditorPlugins['tabEnd'] = { class: TabEndTool };
}
// --- Editor.js Initialization ---
var existingContent = document.getElementById('editorContent').value;
var initialData = null;
@@ -400,7 +425,18 @@
try {
initialData = JSON.parse(existingContent);
} catch (e) {
console.warn('[SIS Editor] Existing content is not valid JSON, starting fresh.');
console.warn('[SIS Editor] Existing content is not valid JSON, treating as Raw HTML.');
initialData = {
time: Date.now(),
blocks: [
{
type: "raw",
data: {
html: existingContent
}
}
]
};
}
}
@@ -490,7 +526,7 @@
var eventTimeGroup = document.getElementById('eventTimeGroup');
var eventLocationGroup = document.getElementById('eventLocationGroup');
function toggleEventTime() {
if (layoutSelect.value === 'EVENT') {
if (layoutSelect.value === 'EVENT' || layoutSelect.value === 'TRAINING') {
eventTimeGroup.style.display = 'block';
if (eventLocationGroup) eventLocationGroup.style.display = 'block';
} else {
@@ -58,8 +58,14 @@
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
data.blocks.forEach(function(block) {
switch(block.type) {
var inTabs = false;
var tabsGroup = null;
function renderSingleBlock(block) {
var html = "";
switch(block.type) {
case "header":
html += "<h" + block.data.level + ">" + block.data.text + "</h" + block.data.level + ">";
break;
@@ -80,10 +86,115 @@
case "quote":
html += "<blockquote>" + block.data.text + " <cite>" + block.data.caption + "</cite></blockquote>";
break;
default:
console.log("Unknown block type", block.type);
}
});
case "raw":
html += block.data.html;
break;
case "tabStart":
case "tabEnd":
break;
default:
console.log("Unknown block type", block.type);
}
return html;
}
var blockList = data && data.blocks ? data.blocks : (typeof blocks !== 'undefined' ? blocks : []);
var finalHtml = "";
blockList.forEach(function(block) {
if (block.type === 'tabStart') {
if (!inTabs) {
inTabs = true;
tabsGroup = { tabs: [] };
}
tabsGroup.tabs.push({
title: block.data.title || 'Tab',
contentHtml: ''
});
return;
}
if (block.type === 'tabEnd') {
if (inTabs && tabsGroup) {
inTabs = false;
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
tabsGroup = null;
}
return;
}
var blockHtml = renderSingleBlock(block);
if (inTabs && tabsGroup && tabsGroup.tabs.length > 0) {
tabsGroup.tabs[tabsGroup.tabs.length - 1].contentHtml += blockHtml;
} else {
finalHtml += blockHtml;
}
});
if (inTabs && tabsGroup) {
// Close unclosed tabs at the end
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
}
html = finalHtml;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var container = btn.closest('.custom-tabs-wrapper');
if (!container) return;
var idx = btn.getAttribute('data-tab-idx');
container.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
container.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = container.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
// Assign back to whatever variable the original script used.
}
parsedContainer.innerHTML = html;
} catch(e) {
@@ -111,8 +111,14 @@
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
data.blocks.forEach(function(block) {
switch(block.type) {
var inTabs = false;
var tabsGroup = null;
function renderSingleBlock(block) {
var html = "";
switch(block.type) {
case "paragraph":
html += "<p>" + block.data.text + "</p>";
break;
@@ -130,6 +136,117 @@
case "image":
html += "<figure><img src='" + block.data.file.url + "' alt='" + (block.data.caption || "") + "'><figcaption>" + (block.data.caption || "") + "</figcaption></figure>";
break;
case "tabStart":
case "tabEnd":
break;
default:
console.log("Unknown block type", block.type);
}
return html;
}
var blockList = data && data.blocks ? data.blocks : (typeof blocks !== 'undefined' ? blocks : []);
var finalHtml = "";
blockList.forEach(function(block) {
if (block.type === 'tabStart') {
if (!inTabs) {
inTabs = true;
tabsGroup = { tabs: [] };
}
tabsGroup.tabs.push({
title: block.data.title || 'Tab',
contentHtml: ''
});
return;
}
if (block.type === 'tabEnd') {
if (inTabs && tabsGroup) {
inTabs = false;
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
tabsGroup = null;
}
return;
}
var blockHtml = renderSingleBlock(block);
if (inTabs && tabsGroup && tabsGroup.tabs.length > 0) {
tabsGroup.tabs[tabsGroup.tabs.length - 1].contentHtml += blockHtml;
} else {
finalHtml += blockHtml;
}
});
if (inTabs && tabsGroup) {
// Close unclosed tabs at the end
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
}
html = finalHtml;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var container = btn.closest('.custom-tabs-wrapper');
if (!container) return;
var idx = btn.getAttribute('data-tab-idx');
container.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
container.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = container.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
// Assign back to whatever variable the original script used.
html += "</" + tag + ">";
break;
case "image":
html += "<figure><img src='" + block.data.file.url + "' alt='" + (block.data.caption || "") + "'><figcaption>" + (block.data.caption || "") + "</figcaption></figure>";
break;
default:
break;
}
@@ -104,8 +104,14 @@
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
data.blocks.forEach(function(block) {
switch(block.type) {
var inTabs = false;
var tabsGroup = null;
function renderSingleBlock(block) {
var html = "";
switch(block.type) {
case "header":
html += "<h" + block.data.level + ">" + block.data.text + "</h" + block.data.level + ">";
break;
@@ -126,10 +132,115 @@
case "quote":
html += "<blockquote>" + block.data.text + " <cite>" + block.data.caption + "</cite></blockquote>";
break;
default:
console.log("Unknown block type", block.type);
}
});
case "raw":
html += block.data.html;
break;
case "tabStart":
case "tabEnd":
break;
default:
console.log("Unknown block type", block.type);
}
return html;
}
var blockList = data && data.blocks ? data.blocks : (typeof blocks !== 'undefined' ? blocks : []);
var finalHtml = "";
blockList.forEach(function(block) {
if (block.type === 'tabStart') {
if (!inTabs) {
inTabs = true;
tabsGroup = { tabs: [] };
}
tabsGroup.tabs.push({
title: block.data.title || 'Tab',
contentHtml: ''
});
return;
}
if (block.type === 'tabEnd') {
if (inTabs && tabsGroup) {
inTabs = false;
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
tabsGroup = null;
}
return;
}
var blockHtml = renderSingleBlock(block);
if (inTabs && tabsGroup && tabsGroup.tabs.length > 0) {
tabsGroup.tabs[tabsGroup.tabs.length - 1].contentHtml += blockHtml;
} else {
finalHtml += blockHtml;
}
});
if (inTabs && tabsGroup) {
// Close unclosed tabs at the end
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
}
html = finalHtml;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var container = btn.closest('.custom-tabs-wrapper');
if (!container) return;
var idx = btn.getAttribute('data-tab-idx');
container.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
container.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = container.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
// Assign back to whatever variable the original script used.
}
parsedContainer.innerHTML = html;
} catch(e) {
@@ -177,8 +177,14 @@
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
data.blocks.forEach(function(block) {
switch(block.type) {
var inTabs = false;
var tabsGroup = null;
function renderSingleBlock(block) {
var html = "";
switch(block.type) {
case "header":
html += "<h" + block.data.level + ">" + block.data.text + "</h" + block.data.level + ">";
break;
@@ -199,10 +205,115 @@
case "quote":
html += "<blockquote>" + block.data.text + " <cite>" + block.data.caption + "</cite></blockquote>";
break;
default:
console.log("Unknown block type", block.type);
}
});
case "raw":
html += block.data.html;
break;
case "tabStart":
case "tabEnd":
break;
default:
console.log("Unknown block type", block.type);
}
return html;
}
var blockList = data && data.blocks ? data.blocks : (typeof blocks !== 'undefined' ? blocks : []);
var finalHtml = "";
blockList.forEach(function(block) {
if (block.type === 'tabStart') {
if (!inTabs) {
inTabs = true;
tabsGroup = { tabs: [] };
}
tabsGroup.tabs.push({
title: block.data.title || 'Tab',
contentHtml: ''
});
return;
}
if (block.type === 'tabEnd') {
if (inTabs && tabsGroup) {
inTabs = false;
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
tabsGroup = null;
}
return;
}
var blockHtml = renderSingleBlock(block);
if (inTabs && tabsGroup && tabsGroup.tabs.length > 0) {
tabsGroup.tabs[tabsGroup.tabs.length - 1].contentHtml += blockHtml;
} else {
finalHtml += blockHtml;
}
});
if (inTabs && tabsGroup) {
// Close unclosed tabs at the end
var groupHtml = '<div class="custom-tabs-wrapper">';
groupHtml += '<div class="custom-tabs-nav-container"><div class="tabs-container">';
tabsGroup.tabs.forEach(function(tab, index) {
var activeClass = (index === 0) ? 'active' : '';
groupHtml += '<button class="dao-tao-tab-btn custom-tab-btn ' + activeClass + '" data-tab-idx="' + index + '">' + tab.title + '</button>';
});
groupHtml += '</div>';
tabsGroup.tabs.forEach(function(tab, index) {
var displayStyle = (index === 0) ? 'block' : 'none';
groupHtml += '<div class="dao-tao-tab-panel custom-tab-panel" data-tab-idx="' + index + '" style="display: ' + displayStyle + ';">';
groupHtml += tab.contentHtml;
groupHtml += '</div>';
});
groupHtml += '</div>';
finalHtml += groupHtml;
}
html = finalHtml;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-tab-btn')) {
var btn = e.target;
var container = btn.closest('.custom-tabs-wrapper');
if (!container) return;
var idx = btn.getAttribute('data-tab-idx');
container.querySelectorAll('.custom-tab-btn').forEach(function(b) {
b.classList.remove('active');
});
btn.classList.add('active');
container.querySelectorAll('.custom-tab-panel').forEach(function(p) {
p.style.display = 'none';
});
var activePanel = container.querySelector('.custom-tab-panel[data-tab-idx="' + idx + '"]');
if (activePanel) activePanel.style.display = 'block';
}
});
// Assign back to whatever variable the original script used.
}
parsedContainer.innerHTML = html;
} catch(e) {
@@ -0,0 +1,92 @@
package com.sisvietnamvn.web;
import com.sisvietnamvn.web.domain.Category;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.PostLayout;
import com.sisvietnamvn.web.repository.CategoryRepository;
import com.sisvietnamvn.web.repository.PostRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.Optional;
@SpringBootTest(classes = SisvietnamvnApp.class)
public class SeedCourseTest {
@Autowired
private CategoryRepository categoryRepository;
@Autowired
private PostRepository postRepository;
@Test
public void seedNoiSoiCourse() throws IOException {
System.out.println("=========================================");
System.out.println("STARTING SEED COURSE OPERATION...");
System.out.println("=========================================");
// 1. Find or create the Category 'dao-tao'
String categorySlug = "dao-tao";
Category category = categoryRepository.findBySlug(categorySlug).orElseGet(() -> {
Category cat = new Category();
// Generate id dynamically
Long nextId = categoryRepository.count() + 1;
cat.setId(nextId);
cat.setName("Đào tạo");
cat.setSlug(categorySlug);
cat.setDescription("Chương trình đào tạo y khoa");
cat.setCreatedBy("system");
cat.setCreatedDate(Instant.now());
cat = categoryRepository.save(cat);
System.out.println("Created new Category 'dao-tao' with ID: " + cat.getId());
return cat;
});
// 2. Read content from parsed files
String content = Files.readString(Paths.get("/tmp/course_content.html"));
String excerpt = Files.readString(Paths.get("/tmp/course_excerpt.html"));
// 3. Find or create the Post
String postSlug = "chuong-trinh-chung-chi-dao-tao-ky-thuat-chuyen-mon-noi-soi-da-day-noi-soi-dai-trang-va-noi-soi-dieu-tri-co-ban-khoa-02";
Optional<Post> postOpt = postRepository.findBySlug(postSlug);
Post post;
if (postOpt.isPresent()) {
post = postOpt.get();
System.out.println("Found existing Post with ID: " + post.getId() + ", updating...");
} else {
post = new Post();
// Generate id dynamically
Long nextId = postRepository.count() + 1000L;
post.setId(nextId);
post.setSlug(postSlug);
post.setCreatedBy("system");
post.setCreatedDate(Instant.now());
System.out.println("Creating new Post with ID: " + post.getId());
}
post.setTitle("CHƯƠNG TRÌNH CHỨNG CHỈ ĐÀO TẠO KỸ THUẬT CHUYÊN MÔN: NỘI SOI DẠ DÀY, NỘI SOI ĐẠI TRÀNG VÀ NỘI SOI ĐIỀU TRỊ CƠ BẢN, KHÓA 02");
post.setContent(content);
post.setExcerpt(excerpt);
post.setLocation("Giảng đường 3A, lầu 3, khu A, Bệnh viện Đại học Y Dược Thành phố Hồ Chí Minh (215 Hồng Bàng, Phường Chợ Lớn, Thành phố Hồ Chí Minh)");
post.setMetaDescription("38.000.000 VNĐ");
post.setEventTime(Instant.parse("2026-07-27T07:30:00Z"));
post.setStatus(PageStatus.PUBLISHED);
post.setLayout(PostLayout.TRAINING);
post.setCategory(category);
post.setFeaturedImage("https://console.bvdaihoc.com.vn/uploads/KHDT/T%E1%BB%95%20S%E1%BB%B1%20Ki%E1%BB%87n%202026/07/N%E1%BB%98I-SOI-D%E1%BA%A0-D%C3%80Y,-N%E1%BB%98I-SOI-%C4%90%E1%BA%A0I-TR%C3%80NG-V%C3%80-N%E1%BB%98I-SOI-%C4%90I%E1%BB%80U-TR%E1%BB%8A-C%C6%A0-B%E1%BA%A2N-(1).gif");
postRepository.save(post);
System.out.println("=========================================");
System.out.println("SEED COURSE COMPLETED SUCCESSFULLY!");
System.out.println("URL path: /dao-tao/" + postSlug);
System.out.println("=========================================");
}
}