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