diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 9b6baef3..e6f3fd5f 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -42,3 +42,6 @@ Instead, ALWAYS use the `activeTheme` variable to dynamically load the active th **Incorrect Usage:** `
` `` + +### Admin Panel UI Pattern: Lists +When building admin panels to manage lists of items, DO NOT use inline `` fields for all rows by default. Instead, use a **read-only view** for the list items, providing explicit **"Edit"** and **"Delete"** buttons for each row. Clicking "Edit" should open a modal or expand the row to allow editing the fields. diff --git a/BV_DHYD_HCM/temp.html b/BV_DHYD_HCM/temp.html index a529866f..b0324f01 100644 --- a/BV_DHYD_HCM/temp.html +++ b/BV_DHYD_HCM/temp.html @@ -3,380 +3,278 @@ - Thành tựu và Bài viết Bệnh viện + Trang Tin Tức - Bài Viết Nổi Bật -
- -
- - - - -
+
+ +
+ +
+ Tin tức + +
+ Thư cảm tạ + +
- -
- -
-
- Bài viết 1 - 2026 -
-
-

- Bệnh viện Đại học Y Dược TP. Hồ Chí Minh ghi dấu ấn tại giải - thưởng "Đại sứ truyền thông... -

-
-
- - - +

+ Tuần lễ thế giới nuôi con bằng sữa mẹ 2026 +

+ +
- - +

+ Đau lưng ở nhân viên văn phòng: Đừng đợi đến khi cột sống “lên + tiếng” +

+ +
- - +

+ ‘Bệnh lý miễn dịch thần kinh - Phát hiện sớm để điều trị tốt’ +

+ +
- - - - -
-
- Bài viết 6 - 2025 -
-
-

- Bệnh viện Đại học Y Dược TP. Hồ Chí Minh - Điển hình tiên tiến - Ngành Y tế tại Đại hội Thi đ... -

-
-
- - -
-
- Bài viết 7 - 2025 -
-
-

- Bệnh viện Đại học Y Dược TP. Hồ Chí Minh tổ chức lễ kỷ niệm 100 ca - TAVI thành công trao... -

-
-
- - -
-
- Bài viết 8 - 2025 -
-
-

- Bệnh viện Đại học Y Dược TP. Hồ Chí Minh đạt giải "Vệ sinh tay - xuất sắc năm 2025" -

-
-
- - -
-
- Bài viết 9 - 2025 -
-
-

- Tiến bộ trong y học bào thai: Can thiệp thành công cứu sống thai - nhi mắc khối u nhau thai... -

-
-
+

+ Phẫu thuật nội soi cắt phì đại liên thất và thay van động mạch chủ +

+ +
- - - -
+ diff --git a/sisvietnamvn_main/html_snippets/content_main_blocks/bento_layout_02.html b/sisvietnamvn_main/html_snippets/content_main_blocks/bento_layout_02.html new file mode 100644 index 00000000..80d6f97e --- /dev/null +++ b/sisvietnamvn_main/html_snippets/content_main_blocks/bento_layout_02.html @@ -0,0 +1,160 @@ + + +
+ + + + + +
+ + +
+ +
+
+ Category +
+

Small Post Title

+ +
+
+
+
diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/PageController.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/PageController.java index e95f9f7a..3de28fc2 100644 --- a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/PageController.java +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/PageController.java @@ -419,11 +419,16 @@ public class PageController { if (editorData.containsKey("blocks")) { blocks = (List>) editorData.get("blocks"); for (Map block : blocks) { - if ("snippet".equals(block.get("type"))) { + if ("snippet".equals(block.get("type")) || "cmsPlugin".equals(block.get("type"))) { Map data = (Map) block.get("data"); - if (data != null && data.containsKey("id")) { + if (data != null) { String snippetId = (String) data.get("id"); - data.put("htmlContent", snippetService.getSnippetContent(snippetId)); + if (snippetId == null || snippetId.trim().isEmpty()) { + snippetId = (String) data.get("shortcode"); + } + if (snippetId != null && !snippetId.trim().isEmpty()) { + data.put("htmlContent", snippetService.getSnippetContent(snippetId)); + } } } if (block.containsKey("tunes")) { @@ -464,6 +469,7 @@ public class PageController { model.addAttribute("page", page); model.addAttribute("blocks", blocks); + model.addAttribute("hookManager", hookManager); // If the page contains a 'posts' block, load published posts so the template can render them boolean hasPostsBlock = blocks.stream().anyMatch(b -> "posts".equals(b.get("type"))); diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/priceTable/priceTableAdminController.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/priceTable/priceTableAdminController.java new file mode 100644 index 00000000..76c3a508 --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/priceTable/priceTableAdminController.java @@ -0,0 +1,155 @@ +package com.sisvietnamvn.web.plugins.priceTable; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sisvietnamvn.web.security.AdminContext; +import com.sisvietnamvn.web.service.SettingService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.util.ArrayList; +import java.util.List; + +@Controller +@RequestMapping("/manage/plugins/price-table") +public class priceTableAdminController { + + private final SettingService settingService; + private final AdminContext adminContext; + private final ObjectMapper objectMapper; + private static final String SETTING_KEY = priceTablePlugin.SETTING_KEY; + + public priceTableAdminController(SettingService settingService, AdminContext adminContext, ObjectMapper objectMapper) { + this.settingService = settingService; + this.adminContext = adminContext; + this.objectMapper = objectMapper; + } + + @GetMapping + public String index(Model model) { + if (!adminContext.currentUserCan("manage_options")) { + return "error/403"; + } + + String json = settingService.getValue(SETTING_KEY, "[]"); + List tables = parseTablesJson(json); + + model.addAttribute("tables", tables); + model.addAttribute("tablesJson", json); + model.addAttribute("pageTitle", "Quản lý Bảng giá Dịch vụ (Đa bảng giá)"); + + return "plugins/price-table/admin-settings"; + } + + @PostMapping + public String save(@RequestParam(value = "tableData", required = false) String tableDataJson, RedirectAttributes redirectAttributes) { + if (!adminContext.currentUserCan("manage_options")) { + return "error/403"; + } + + if (tableDataJson != null && !tableDataJson.trim().isEmpty()) { + try { + objectMapper.readValue(tableDataJson, new TypeReference>() {}); + settingService.setValue(SETTING_KEY, tableDataJson); + redirectAttributes.addFlashAttribute("successMessage", "Đã lưu cấu hình bảng giá thành công!"); + } catch (JsonProcessingException e) { + redirectAttributes.addFlashAttribute("errorMessage", "Dữ liệu JSON không hợp lệ: " + e.getMessage()); + } + } else { + settingService.setValue(SETTING_KEY, "[]"); + redirectAttributes.addFlashAttribute("successMessage", "Đã xóa toàn bộ dữ liệu bảng giá!"); + } + + return "redirect:/manage/plugins/price-table"; + } + + public List parseTablesJson(String json) { + if (json == null || json.trim().isEmpty() || "[]".equals(json)) { + return new ArrayList<>(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + return new ArrayList<>(); + } + } + + public static class PriceItem { + private String code; + private String name; + private String unitPrice; + private String bhytPrice; + private String note; + + public PriceItem() {} + public PriceItem(String code, String name, String unitPrice, String bhytPrice, String note) { + this.code = code; + this.name = name; + this.unitPrice = unitPrice; + this.bhytPrice = bhytPrice; + this.note = note; + } + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getUnitPrice() { return unitPrice; } + public void setUnitPrice(String unitPrice) { this.unitPrice = unitPrice; } + public String getBhytPrice() { return bhytPrice; } + public void setBhytPrice(String bhytPrice) { this.bhytPrice = bhytPrice; } + public String getNote() { return note; } + public void setNote(String note) { this.note = note; } + } + + public static class PriceCategory { + private String categoryName; + private List items = new ArrayList<>(); + + public PriceCategory() {} + public PriceCategory(String categoryName, List items) { + this.categoryName = categoryName; + this.items = items; + } + + public String getCategoryName() { return categoryName; } + public void setCategoryName(String categoryName) { this.categoryName = categoryName; } + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + } + + public static class PriceTable { + private String id; + private String name; + private String tab1Name; + private String tab2Name; + private String pdfUrl; + private List categories = new ArrayList<>(); + + public PriceTable() {} + public PriceTable(String id, String name, String tab1Name, String tab2Name, String pdfUrl, List categories) { + this.id = id; + this.name = name; + this.tab1Name = tab1Name; + this.tab2Name = tab2Name; + this.pdfUrl = pdfUrl; + this.categories = categories; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getTab1Name() { return tab1Name; } + public void setTab1Name(String tab1Name) { this.tab1Name = tab1Name; } + public String getTab2Name() { return tab2Name; } + public void setTab2Name(String tab2Name) { this.tab2Name = tab2Name; } + public String getPdfUrl() { return pdfUrl; } + public void setPdfUrl(String pdfUrl) { this.pdfUrl = pdfUrl; } + public List getCategories() { return categories; } + public void setCategories(List categories) { this.categories = categories; } + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/priceTable/priceTablePlugin.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/priceTable/priceTablePlugin.java new file mode 100644 index 00000000..7e403219 --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/priceTable/priceTablePlugin.java @@ -0,0 +1,315 @@ +package com.sisvietnamvn.web.plugins.priceTable; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sisvietnamvn.web.domain.Plugin; +import com.sisvietnamvn.web.domain.PluginStatus; +import com.sisvietnamvn.web.hook.HookManager; +import com.sisvietnamvn.web.repository.ComponentTemplateRepository; +import com.sisvietnamvn.web.repository.HtmlSnippetRepository; +import com.sisvietnamvn.web.repository.PluginRepository; +import com.sisvietnamvn.web.service.SettingService; + +@Component +public class priceTablePlugin { + + private static final Logger LOG = LoggerFactory.getLogger(priceTablePlugin.class); + private static final String PLUGIN_KEY = "price-table-plugin"; + private static final String SNIPPET_SLUG = "price-table-snippet"; + public static final String SETTING_KEY = "plugin_price_table_data"; + + private final PluginRepository pluginRepository; + private final HtmlSnippetRepository snippetRepository; + private final ComponentTemplateRepository templateRepository; + private final HookManager hookManager; + private final SettingService settingService; + private final ObjectMapper objectMapper; + + public priceTablePlugin(PluginRepository pluginRepository, + HtmlSnippetRepository snippetRepository, + ComponentTemplateRepository templateRepository, + HookManager hookManager, + SettingService settingService, + ObjectMapper objectMapper) { + this.pluginRepository = pluginRepository; + this.snippetRepository = snippetRepository; + this.templateRepository = templateRepository; + this.hookManager = hookManager; + this.settingService = settingService; + this.objectMapper = objectMapper; + } + + @EventListener(ApplicationReadyEvent.class) + @Transactional + public void onApplicationReady() { + boolean isActive = false; + try { + Plugin p = pluginRepository.findByPluginKey(PLUGIN_KEY).orElseGet(() -> { + Plugin newPlugin = new Plugin(); + newPlugin.setPluginKey(PLUGIN_KEY); + newPlugin.setName("Bảng giá Dịch vụ & Kỹ thuật (Đa bảng giá)"); + newPlugin.setVersion("2.0"); + newPlugin.setStatus(PluginStatus.ACTIVE); + newPlugin.setAuthor("Antigravity"); + newPlugin.setDescription("Plugin đa bảng giá dịch vụ y tế với hỗ trợ shortcode [plugin:price-table id=\"...\"]"); + return pluginRepository.save(newPlugin); + }); + isActive = p.getStatus() == PluginStatus.ACTIVE; + } catch (Exception e) { + LOG.warn("Could not verify priceTablePlugin status: {}", e.getMessage()); + return; + } + + if (!isActive) { + LOG.info("priceTablePlugin is inactive."); + return; + } + + LOG.info("priceTablePlugin is active. Registering hooks..."); + + // Hook to add menu to Plugins dropdown + hookManager.addFilter("admin_menu_plugins", (content, args) -> { + String html = content instanceof String ? (String) content : ""; + html += "Bảng giá Dịch vụ\n"; + return html; + }, 10); + + // Hook to replace shortcode in content & snippets + hookManager.addFilter("the_content", (content, args) -> { + String text = content instanceof String ? (String) content : ""; + return processShortcodes(text); + }, 10); + + hookManager.addFilter("snippet_content", (content, args) -> { + String text = content instanceof String ? (String) content : ""; + return processShortcodes(text); + }, 10); + } + + private String processShortcodes(String text) { + if (text == null || !text.contains("[plugin:price-table")) { + return text; + } + + // Do NOT replace shortcodes inside raw Editor.js JSON strings to prevent JSON syntax corruption + String trimmed = text.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return text; + } + + // Pattern matches: [plugin:price-table] or [plugin:price-table id="xyz"] or [plugin:price-table slug="xyz"] + Pattern pattern = Pattern.compile("\\[plugin:price-table(?:\\s+(?:id|slug)=\"([^\"]+)\")?\\s*\\]"); + Matcher matcher = pattern.matcher(text); + StringBuilder sb = new StringBuilder(); + + while (matcher.find()) { + String targetId = matcher.group(1); + String replacement = generatePriceTableHtml(targetId); + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(sb); + return sb.toString(); + } + + private String generatePriceTableHtml(String targetId) { + String json = settingService.getValue(SETTING_KEY, "[]"); + List tables = new ArrayList<>(); + try { + tables = objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + LOG.error("Failed to parse priceTable JSON: {}", e.getMessage()); + } + + if (tables.isEmpty()) { + return "

Chưa có dữ liệu Bảng giá.

"; + } + + // Find requested table or default to first table + priceTableAdminController.PriceTable selectedTable = tables.get(0); + if (targetId != null && !targetId.trim().isEmpty()) { + for (priceTableAdminController.PriceTable t : tables) { + if (t.getId() != null && targetId.trim().equalsIgnoreCase(t.getId())) { + selectedTable = t; + break; + } + } + } + + String tableId = sanitizeId(selectedTable.getId() != null ? selectedTable.getId() : "default"); + String tab1Name = selectedTable.getTab1Name() != null && !selectedTable.getTab1Name().isEmpty() ? selectedTable.getTab1Name() : "Bảng giá"; + String tab2Name = selectedTable.getTab2Name() != null && !selectedTable.getTab2Name().isEmpty() ? selectedTable.getTab2Name() : "Danh mục kỹ thuật"; + String pdfUrl = selectedTable.getPdfUrl() != null ? selectedTable.getPdfUrl().trim() : ""; + List categories = selectedTable.getCategories() != null ? selectedTable.getCategories() : new ArrayList<>(); + + StringBuilder html = new StringBuilder(); + html.append(""); + + html.append("
"); + html.append("
"); + html.append("
").append(escapeHtml(tab1Name)).append("
"); + html.append("
").append(escapeHtml(tab2Name)).append("
"); + html.append("
"); + + // TAB 1 CONTENT + html.append("
"); + html.append("
"); + html.append(" "); + html.append(" "); + html.append("
"); + + html.append("
"); + html.append("
"); + html.append("
Tên dịch vụ kỹ thuật
"); + html.append("
Mức thu
"); + html.append("
Mức BHYT
"); + html.append("
"); + + html.append("
"); + for (int cIdx = 0; cIdx < categories.size(); cIdx++) { + priceTableAdminController.PriceCategory cat = categories.get(cIdx); + String catName = cat.getCategoryName() != null ? cat.getCategoryName() : ""; + List items = cat.getItems() != null ? cat.getItems() : new ArrayList<>(); + + html.append("
"); + html.append("
"); + html.append("
").append(escapeHtml(catName)).append("
"); + html.append("
"); + html.append("
"); + html.append("
"); + for (priceTableAdminController.PriceItem item : items) { + String name = item.getName() != null ? item.getName() : ""; + String uPrice = item.getUnitPrice() != null ? item.getUnitPrice() : "-"; + String bPrice = item.getBhytPrice() != null ? item.getBhytPrice() : "-"; + html.append("
"); + html.append("
").append(escapeHtml(name)).append("
"); + html.append("
").append(escapeHtml(uPrice)).append("
"); + html.append("
").append(escapeHtml(bPrice)).append("
"); + html.append("
"); + } + html.append("
"); + html.append("
"); + } + html.append("
"); + html.append("
"); + html.append("
"); // END TAB 1 + + // TAB 2 CONTENT (PDF Viewer) + html.append(" "); // END TAB 2 + + html.append("
"); + + html.append(""); + + return html.toString(); + } + + private String sanitizeId(String id) { + if (id == null) return "default"; + return id.toLowerCase().replaceAll("[^a-z0-9_-]", "_"); + } + + private String escapeHtml(String text) { + if (text == null) return ""; + return text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/recruitingTable/RecruitingTableAdminController.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/recruitingTable/RecruitingTableAdminController.java new file mode 100644 index 00000000..54872fb6 --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/recruitingTable/RecruitingTableAdminController.java @@ -0,0 +1,131 @@ +package com.sisvietnamvn.web.plugins.recruitingTable; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sisvietnamvn.web.security.AdminContext; +import com.sisvietnamvn.web.service.SettingService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.util.ArrayList; +import java.util.List; + +@Controller +@RequestMapping({"/manage/plugins/recruiting-table", "/manage/plugins/recruitment-table"}) +public class RecruitingTableAdminController { + + private final SettingService settingService; + private final AdminContext adminContext; + private final ObjectMapper objectMapper; + public static final String SETTING_KEY = RecruitingTablePlugin.SETTING_KEY; + + public RecruitingTableAdminController(SettingService settingService, AdminContext adminContext, ObjectMapper objectMapper) { + this.settingService = settingService; + this.adminContext = adminContext; + this.objectMapper = objectMapper; + } + + @GetMapping + public String index(Model model) { + if (!adminContext.currentUserCan("manage_options")) { + return "error/403"; + } + + String json = settingService.getValue(SETTING_KEY, "[]"); + List tables = parseTablesJson(json); + + model.addAttribute("tables", tables); + model.addAttribute("tablesJson", json); + model.addAttribute("pageTitle", "Quản lý Bảng Tuyển dụng Nhân sự (Đa danh sách)"); + + return "plugins/recruiting-table/admin-settings"; + } + + @PostMapping + public String save(@RequestParam(value = "tableData", required = false) String tableDataJson, RedirectAttributes redirectAttributes) { + if (!adminContext.currentUserCan("manage_options")) { + return "error/403"; + } + + if (tableDataJson != null && !tableDataJson.trim().isEmpty()) { + try { + objectMapper.readValue(tableDataJson, new TypeReference>() {}); + settingService.setValue(SETTING_KEY, tableDataJson); + redirectAttributes.addFlashAttribute("successMessage", "Đã lưu cấu hình danh sách tuyển dụng thành công!"); + } catch (JsonProcessingException e) { + redirectAttributes.addFlashAttribute("errorMessage", "Dữ liệu JSON không hợp lệ: " + e.getMessage()); + } + } else { + settingService.setValue(SETTING_KEY, "[]"); + redirectAttributes.addFlashAttribute("successMessage", "Đã xóa toàn bộ dữ liệu bảng tuyển dụng!"); + } + + return "redirect:/manage/plugins/recruiting-table"; + } + + public List parseTablesJson(String json) { + if (json == null || json.trim().isEmpty() || "[]".equals(json)) { + return new ArrayList<>(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + return new ArrayList<>(); + } + } + + public static class RecruitingItem { + private String position; + private String quantity; + private String deadline; + private String detailUrl; + + public RecruitingItem() {} + public RecruitingItem(String position, String quantity, String deadline, String detailUrl) { + this.position = position; + this.quantity = quantity; + this.deadline = deadline; + this.detailUrl = detailUrl; + } + + public String getPosition() { return position; } + public void setPosition(String position) { this.position = position; } + public String getQuantity() { return quantity; } + public void setQuantity(String quantity) { this.quantity = quantity; } + public String getDeadline() { return deadline; } + public void setDeadline(String deadline) { this.deadline = deadline; } + public String getDetailUrl() { return detailUrl; } + public void setDetailUrl(String detailUrl) { this.detailUrl = detailUrl; } + } + + public static class RecruitingTable { + private String id; + private String name; + private String description; + private String imageUrl; + private List items = new ArrayList<>(); + + public RecruitingTable() {} + public RecruitingTable(String id, String name, String description, String imageUrl, List items) { + this.id = id; + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + this.items = items; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public String getImageUrl() { return imageUrl; } + public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/recruitingTable/RecruitingTablePlugin.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/recruitingTable/RecruitingTablePlugin.java new file mode 100644 index 00000000..d6d90390 --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/recruitingTable/RecruitingTablePlugin.java @@ -0,0 +1,267 @@ +package com.sisvietnamvn.web.plugins.recruitingTable; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sisvietnamvn.web.domain.Plugin; +import com.sisvietnamvn.web.domain.PluginStatus; +import com.sisvietnamvn.web.hook.HookManager; +import com.sisvietnamvn.web.repository.PluginRepository; +import com.sisvietnamvn.web.service.SettingService; + +@Component +public class RecruitingTablePlugin { + + private static final Logger LOG = LoggerFactory.getLogger(RecruitingTablePlugin.class); + private static final String PLUGIN_KEY = "recruiting-table-plugin"; + public static final String SETTING_KEY = "plugin_recruiting_table_data"; + + private final PluginRepository pluginRepository; + private final HookManager hookManager; + private final SettingService settingService; + private final ObjectMapper objectMapper; + + public RecruitingTablePlugin(PluginRepository pluginRepository, + HookManager hookManager, + SettingService settingService, + ObjectMapper objectMapper) { + this.pluginRepository = pluginRepository; + this.hookManager = hookManager; + this.settingService = settingService; + this.objectMapper = objectMapper; + } + + @EventListener(ApplicationReadyEvent.class) + @Transactional + public void onApplicationReady() { + boolean isActive = false; + try { + Plugin p = pluginRepository.findByPluginKey(PLUGIN_KEY).orElseGet(() -> { + Plugin newPlugin = new Plugin(); + newPlugin.setPluginKey(PLUGIN_KEY); + newPlugin.setName("Danh sách Tuyển dụng Nhân sự"); + newPlugin.setVersion("1.0"); + newPlugin.setStatus(PluginStatus.ACTIVE); + newPlugin.setAuthor("Antigravity"); + newPlugin.setDescription("Plugin danh sách vị trí tuyển dụng nhân sự hỗ trợ shortcode [plugin:recruiting-table id=\"...\"]"); + return pluginRepository.save(newPlugin); + }); + isActive = p.getStatus() == PluginStatus.ACTIVE; + } catch (Exception e) { + LOG.warn("Could not verify RecruitingTablePlugin status: {}", e.getMessage()); + return; + } + + if (!isActive) { + LOG.info("RecruitingTablePlugin is inactive."); + return; + } + + LOG.info("RecruitingTablePlugin is active. Registering hooks..."); + + // Hook to add menu item in Admin Plugins dropdown + hookManager.addFilter("admin_menu_plugins", (content, args) -> { + String html = content instanceof String ? (String) content : ""; + html += "Tuyển dụng Nhân sự\n"; + return html; + }, 10); + + // Hook to replace shortcodes in page content & snippets + hookManager.addFilter("the_content", (content, args) -> { + String text = content instanceof String ? (String) content : ""; + return processShortcodes(text); + }, 10); + + hookManager.addFilter("snippet_content", (content, args) -> { + String text = content instanceof String ? (String) content : ""; + return processShortcodes(text); + }, 10); + + // Seed initial default recruiting list if database setting is empty + seedDefaultData(); + } + + private void seedDefaultData() { + String existing = settingService.getValue(SETTING_KEY, null); + if (existing == null || existing.trim().isEmpty() || "[]".equals(existing.trim())) { + List defaultList = new ArrayList<>(); + + List items = new ArrayList<>(); + items.add(new RecruitingTableAdminController.RecruitingItem("Bác sĩ Cấp cứu", "01", "31/08/2026", "#")); + items.add(new RecruitingTableAdminController.RecruitingItem("Bác sĩ Huyết học", "01", "30/06/2026", "#")); + items.add(new RecruitingTableAdminController.RecruitingItem("Bác sĩ Xét nghiệm", "01", "30/06/2026", "#")); + items.add(new RecruitingTableAdminController.RecruitingItem("Bác sĩ Vi sinh", "01", "30/06/2026", "#")); + + RecruitingTableAdminController.RecruitingTable defaultTable = new RecruitingTableAdminController.RecruitingTable( + "recruiting_1", + "Danh sách vị trí tuyển dụng Bác sĩ", + "Danh sách nhu cầu tuyển dụng các vị trí Bác sĩ chuyên khoa tại bệnh viện S.I.S Cần Thơ", + null, + items + ); + defaultList.add(defaultTable); + + try { + settingService.setValue(SETTING_KEY, objectMapper.writeValueAsString(defaultList)); + LOG.info("Seeded default recruiting table data."); + } catch (Exception e) { + LOG.error("Failed to seed default recruiting table data: {}", e.getMessage()); + } + } + } + + private String processShortcodes(String text) { + if (text == null || (!text.contains("[plugin:recruiting-table") && !text.contains("[plugin:recruitment-table") && !text.contains("[plugin:price-table"))) { + return text; + } + + // Do NOT replace shortcodes inside raw Editor.js JSON strings + String trimmed = text.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return text; + } + + // Matches: [plugin:recruiting-table] or [plugin:recruitment-table] or [plugin:price-table] (with optional id="..." or slug="...") + Pattern pattern = Pattern.compile("\\[plugin:(?:recruiting-table|recruitment-table|price-table)(?:\\s+(?:id|slug)=\"([^\"]+)\")?\\s*\\]"); + Matcher matcher = pattern.matcher(text); + StringBuilder sb = new StringBuilder(); + + while (matcher.find()) { + String targetId = matcher.group(1); + String replacement = generateRecruitingTableHtml(targetId); + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(sb); + return sb.toString(); + } + + public String generateRecruitingTableHtml(String targetId) { + String json = settingService.getValue(SETTING_KEY, "[]"); + List tables = new ArrayList<>(); + try { + tables = objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + LOG.error("Failed to parse recruiting table JSON: {}", e.getMessage()); + } + + if (tables.isEmpty()) { + return "
Chưa có thông tin tuyển dụng.
"; + } + + // Find requested table or default to first + RecruitingTableAdminController.RecruitingTable selectedTable = tables.get(0); + if (targetId != null && !targetId.trim().isEmpty()) { + for (RecruitingTableAdminController.RecruitingTable t : tables) { + if (t.getId() != null && targetId.trim().equalsIgnoreCase(t.getId())) { + selectedTable = t; + break; + } + } + } + + String tableId = sanitizeId(selectedTable.getId() != null ? selectedTable.getId() : "default"); + String imageUrl = selectedTable.getImageUrl(); + List items = selectedTable.getItems() != null ? selectedTable.getItems() : new ArrayList<>(); + + StringBuilder html = new StringBuilder(); + html.append(""); + + html.append("
"); + if (imageUrl != null && !imageUrl.trim().isEmpty()) { + html.append("
"); + html.append("
"); + html.append(" ").append(escapeHtml(selectedTable.getName())).append(""); + html.append("
"); + html.append("
"); + } + html.append("
"); + html.append(" "); + html.append(" "); + html.append(" "); + html.append(" "); + html.append(" "); + html.append(" "); + html.append(" "); + html.append(" "); + html.append(" "); + html.append(" "); + + if (items.isEmpty()) { + html.append(" "); + } else { + for (RecruitingTableAdminController.RecruitingItem item : items) { + String pos = item.getPosition() != null ? item.getPosition() : ""; + String qty = item.getQuantity() != null ? item.getQuantity() : "01"; + String dl = item.getDeadline() != null ? item.getDeadline() : ""; + String url = item.getDetailUrl() != null && !item.getDetailUrl().trim().isEmpty() ? item.getDetailUrl().trim() : "#"; + + html.append(" "); + html.append(" "); + html.append(" "); + html.append(" "); + html.append(" "); + html.append(" "); + } + } + + html.append(" "); + html.append("
Vị trí tuyển dụng ˅Số lượng ˅Thời hạn ˅
Hiện chưa có vị trí tuyển dụng nào.
"); + String targetUrl = (!"#".equals(url) && !url.isEmpty()) ? url : "javascript:void(0);"; + html.append(" ").append(escapeHtml(pos)).append(""); + html.append(" ").append(escapeHtml(qty)).append("").append(escapeHtml(dl)).append(""); + if (!"#".equals(url)) { + html.append(" Xem chi tiết →"); + } else { + html.append(" Xem chi tiết →"); + } + html.append("
"); + html.append("
"); + + if (imageUrl != null && !imageUrl.trim().isEmpty()) { + html.append("
"); // end table-col + html.append("
"); // end layout + } + + html.append("
"); + + return html.toString(); + } + + private String sanitizeId(String id) { + if (id == null) return "default"; + return id.toLowerCase().replaceAll("[^a-z0-9_-]", "_"); + } + + private String escapeHtml(String text) { + if (text == null) return ""; + return text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/service/HtmlSnippetService.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/service/HtmlSnippetService.java index 150237ff..f64e71fa 100644 --- a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/service/HtmlSnippetService.java +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/service/HtmlSnippetService.java @@ -1,71 +1,74 @@ package com.sisvietnamvn.web.service; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import com.sisvietnamvn.web.domain.HtmlSnippet; +import com.sisvietnamvn.web.domain.MenuItem; +import com.sisvietnamvn.web.hook.HookManager; import com.sisvietnamvn.web.repository.HtmlSnippetRepository; -import com.sisvietnamvn.web.service.dto.WidgetDto; +import com.sisvietnamvn.web.repository.MenuItemRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; -/** - * Service for managing HtmlSnippets and providing them to Thymeleaf. - */ -@Service("snippetService") // Named bean so it can be called from Thymeleaf via @snippetService -@Transactional +@Service("snippetService") public class HtmlSnippetService { - private final HtmlSnippetRepository snippetRepository; - private final SettingService settingService; - private final ObjectMapper objectMapper; - private final ComponentTemplateService componentTemplateService; - private final com.sisvietnamvn.web.hook.HookManager hookManager; + private static final Logger LOG = LoggerFactory.getLogger(HtmlSnippetService.class); - public HtmlSnippetService(HtmlSnippetRepository snippetRepository, SettingService settingService, ObjectMapper objectMapper, ComponentTemplateService componentTemplateService, com.sisvietnamvn.web.hook.HookManager hookManager) { + private final HtmlSnippetRepository snippetRepository; + private final MenuItemRepository menuItemRepository; + private final ComponentTemplateService componentTemplateService; + private final HookManager hookManager; + + public HtmlSnippetService(HtmlSnippetRepository snippetRepository, + MenuItemRepository menuItemRepository, + ComponentTemplateService componentTemplateService, + HookManager hookManager) { this.snippetRepository = snippetRepository; - this.settingService = settingService; - this.objectMapper = objectMapper; + this.menuItemRepository = menuItemRepository; this.componentTemplateService = componentTemplateService; this.hookManager = hookManager; } + @Transactional(readOnly = true) public List findAll() { return snippetRepository.findAll(); } + @Transactional(readOnly = true) public Optional findById(Long id) { return snippetRepository.findById(id); } - public HtmlSnippet save(HtmlSnippet snippet) { - return snippetRepository.save(snippet); - } - - public void deleteById(Long id) { - snippetRepository.deleteById(id); + @Transactional(readOnly = true) + public Optional findBySlug(String slug) { + return snippetRepository.findBySlug(slug); } /** - * Gets the content of a snippet by slug. - * If the snippet is not found or is inactive, returns an empty string. - * Can be called in Thymeleaf using: ${@snippetService.getSnippetContent('slug')} - * - * @param slug the unique slug of the snippet - * @return the HTML content or empty string + * Gets the content of a snippet by slug or direct shortcode. + * If the snippet is not found by slug in DB, but the input looks like a shortcode, + * it processes the shortcode directly. */ @Transactional(readOnly = true) public String getSnippetContent(String slug) { - if (slug == null || slug.isEmpty()) { + if (slug == null || slug.trim().isEmpty()) { return ""; } - String content = snippetRepository.findBySlug(slug) + String trimmedSlug = slug.trim(); + String content = snippetRepository.findBySlug(trimmedSlug) .filter(HtmlSnippet::isActive) .map(HtmlSnippet::getContent) .orElse(""); - + + // If no snippet found in DB, check if the input is a direct shortcode + if (content.isEmpty() && (trimmedSlug.contains("[") || trimmedSlug.contains("plugin:"))) { + content = trimmedSlug; + } + if (content.isEmpty()) return content; if (content.contains("[widgets:sidebar]")) { @@ -79,7 +82,6 @@ public class HtmlSnippetService { } // Process shortcodes for menus, e.g., [menu:INFO] or [menu:INFO:dropdown] - // Support HTML entities [ and ] in case the WYSIWYG editor escaped them java.util.regex.Matcher m = java.util.regex.Pattern.compile("(?:\\[|[)menu:([a-zA-Z0-9_-]+)(?::([a-zA-Z0-9_-]+))?(?:\\]|])").matcher(content); StringBuffer sb = new StringBuffer(); while (m.find()) { @@ -90,10 +92,7 @@ public class HtmlSnippetService { m.appendTail(sb); content = sb.toString(); - // Process component template shortcodes: - // [component:SLUG] - // [component:SLUG data-source="menu:CTA"] - // Also supports HTML-escaped brackets + // Process component template shortcodes: [component:SLUG] java.util.regex.Matcher cm = java.util.regex.Pattern.compile( "(?:\\[|[)component:([a-zA-Z0-9_-]+)(?:\\s+data-source=\"([^\"]+)\")?(?:\\]|])" ).matcher(content); @@ -109,7 +108,7 @@ public class HtmlSnippetService { content = (String) hookManager.applyFilters("snippet_content", content); - return "\n\n" + content + "\n\n"; + return "\n\n" + content + "\n\n"; } private String renderMenu(String location, String style) { @@ -118,21 +117,16 @@ public class HtmlSnippetService { } private String renderWidgets(String area) { - String json = settingService.getValue("theme_widgets_" + area, "[]"); - try { - List widgets = objectMapper.readValue(json, new TypeReference>() {}); - StringBuilder sb = new StringBuilder(); - for (WidgetDto w : widgets) { - sb.append("
"); - if (w.getTitle() != null && !w.getTitle().isEmpty()) { - sb.append("
").append(w.getTitle()).append("
"); - } - sb.append("
").append(w.getContent()).append("
"); - sb.append("
"); - } - return sb.toString(); - } catch (Exception e) { - return ""; - } + return ""; + } + + @Transactional + public HtmlSnippet save(HtmlSnippet snippet) { + return snippetRepository.save(snippet); + } + + @Transactional + public void deleteById(Long id) { + snippetRepository.deleteById(id); } } diff --git a/sisvietnamvn_main/src/main/resources/static/css/custom.css b/sisvietnamvn_main/src/main/resources/static/css/custom.css index 7372fc66..c2168f53 100644 --- a/sisvietnamvn_main/src/main/resources/static/css/custom.css +++ b/sisvietnamvn_main/src/main/resources/static/css/custom.css @@ -255,7 +255,7 @@ i, } .tabs-container { - background-color: var(--color-old-brick); + background-color: white; border-radius: 8px; padding: 4px; display: flex; @@ -263,13 +263,18 @@ i, width: 100%; max-width: 860px; margin-bottom: calc(var(--spacing) * 8); + color: var(--color-old-brick); + .active { + background-color: var(--color-old-brick); + color: white; + } } .custom-tab-btn { padding: 8px 16px; border: none; background-color: transparent; - color: white; + color: var(--color-old-brick); cursor: pointer; border-radius: 6px; transition: all 0.2s ease-in-out; @@ -280,10 +285,10 @@ i, min-width: 120px; } -.custom-tab-btn:hover { +/* .custom-tab-btn:hover { color: rgba(255, 255, 255, 0.8); background-color: transparent; -} +} */ /* --- Utility Classes --- */ .mb-4 { @@ -581,6 +586,34 @@ figure.table table tr:hover { box-shadow: 0 0 30px #0000001a; } +.sis-grid-image-wrapper img.rounded { + border-radius: 0.5rem; +} + +.align-content-center { + align-content: center; +} + +#wrapperCrowdDoctors .sis-grid-image-wrapper { + position: relative; +} + +.recruit-hero-banner #crowdDoctors { + position: absolute; + /* top: 50%; */ + /* left: 50%; */ + width: 640px; + height: auto; +} + +.hover-gia-tri-cot-loi { + height: 432px; +} + +.wrapper-gia-tri-cot-loi { + background-color: #f0aeae; +} + /* ========================================================================== 5. Responsive Breakpoints — 3 Groups Only ========================================================================== */ @@ -779,6 +812,47 @@ figure.table table tr:hover { border-top: 2px solid #881c1c; padding-top: 10px; } + + #wrapperCrowdDoctors { + display: none; + } +} + +/* --- Responsive Tab Plugin (Switch to Select / Dropdown on Mobile & Tablet <= 1024px) --- */ +@media screen and (max-width: 1024px) { + .sis-tab-group-wrapper .tabs-container { + display: none !important; + } + .sis-tab-select-wrapper { + display: block !important; + } +} + +@media screen and (min-width: 1025px) { + .sis-tab-select-wrapper { + display: none !important; + } + .sis-tab-group-wrapper .tabs-container { + display: flex !important; + } +} + +.custom-tab-select { + border: 2px solid var(--color-old-brick, #9b1c2b) !important; + color: var(--color-old-brick, #9b1c2b) !important; + font-weight: 700 !important; + font-size: 1rem !important; + border-radius: 8px !important; + padding: 10px 15px !important; + background-color: #fff !important; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06) !important; + width: 100% !important; + cursor: pointer; +} + +.custom-tab-select:focus { + outline: none !important; + box-shadow: 0 0 0 3px rgba(155, 28, 43, 0.25) !important; } /* --- TABLET ONLY (769px – 1024px) --- */ @@ -1046,6 +1120,14 @@ figure.table table tr:hover { .umass-platform-homepage .header-hospital-name { display: block; } + + #wrapperCrowdDoctors { + display: none; + } + + .hover-gia-tri-cot-loi { + height: 600px; + } } /* --- DESKTOP ONLY (≥ 1025px) --- */ @@ -1737,6 +1819,137 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */ .sis-hover-card-v2 .sis-hover-card__title { font-size: 1.05rem !important; } + + .hover-gia-tri-cot-loi { + height: 850px; + } +} + +/* ========================================================================== + Hover Cards Variant 3 (Hover 3 — Horizontal Layout) + ========================================================================== */ +.sis-hover-card-v3-section { + padding-top: 30px; + padding-bottom: 30px; +} + +.sis-hover-card-v3-section .active img.sis-hover-card__img-main { + top: 0px !important; + left: 0px !important; +} + +.sis-hover-card-v3 { + background-color: #ffffff; + border: 1px solid #e6f0fa; + border-radius: 12px; + box-shadow: 0 4px 15px rgba(0, 37, 84, 0.06); + padding: 24px; + display: flex !important; + flex-direction: row !important; + align-items: center !important; + justify-content: flex-start !important; + text-align: left !important; + cursor: pointer; + transition: all 0.35s cubic-bezier(0.25, 0.8, 0.25, 1); + position: relative; + overflow: hidden; + height: 100%; +} + +.sis-hover-card-v3 .sis-hover-card__icon-wrapper { + margin-right: 20px !important; + flex-shrink: 0 !important; +} + +.sis-hover-card-v3 .sis-hover-card__icon { + display: flex !important; + align-items: center !important; + justify-content: center !important; + height: 70px !important; + width: 70px !important; + position: relative !important; + margin: 0 !important; +} + +.sis-hover-card-v3 .sis-hover-card__icon img { + height: 60px !important; + width: auto !important; + max-width: 100% !important; + object-fit: contain !important; + transition: all 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important; + margin: 0 !important; +} + +.sis-hover-card-v3 .sis-hover-card__content { + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + flex-grow: 1 !important; +} + +.sis-hover-card-v3 .sis-hover-card__title { + color: #0d71ba !important; + font-size: 1.25rem !important; + font-weight: 700 !important; + margin-bottom: 8px !important; + transition: color 0.35s ease !important; +} + +.sis-hover-card-v3 .sis-hover-card__desc { + color: #555555 !important; + font-size: 0.95rem !important; + line-height: 1.5 !important; + margin: 0 !important; + transition: color 0.35s ease !important; + opacity: 1 !important; + max-height: none !important; + overflow: visible !important; +} + +/* Hover 3 Active / Hover State */ +.sis-hover-card-v3:hover, +.sis-hover-card-v3.active { + background-color: #0d71ba !important; + border-color: #0d71ba !important; + transform: translateY(-4px) !important; + box-shadow: 0 12px 28px rgba(13, 113, 186, 0.28) !important; +} + +.sis-hover-card-v3:hover .sis-hover-card__title, +.sis-hover-card-v3.active .sis-hover-card__title, +.sis-hover-card-v3:hover .sis-hover-card__desc, +.sis-hover-card-v3.active .sis-hover-card__desc { + color: #ffffff !important; +} + +/* Hover 3 Image Swapping */ +.sis-hover-card-v3:hover .sis-hover-card__img-main:has(~ .sis-hover-card__img-hover), +.sis-hover-card-v3.active .sis-hover-card__img-main:has(~ .sis-hover-card__img-hover) { + opacity: 0 !important; +} + +.sis-hover-card-v3:hover .sis-hover-card__img-hover, +.sis-hover-card-v3.active .sis-hover-card__img-hover { + opacity: 1 !important; + transform: scale(1.1) !important; +} + +/* Fallback image filter if hover image is NOT present */ +.sis-hover-card-v3:hover .sis-hover-card__icon img:not(.sis-hover-card__img-hover):not(:has(~ .sis-hover-card__img-hover)), +.sis-hover-card-v3.active .sis-hover-card__icon img:not(.sis-hover-card__img-hover):not(:has(~ .sis-hover-card__img-hover)) { + filter: brightness(0) invert(1) !important; + transform: scale(1.1) !important; +} + +@media (max-width: 576px) { + .sis-hover-card-v3 { + flex-direction: column !important; + text-align: center !important; + } + .sis-hover-card-v3 .sis-hover-card__icon-wrapper { + margin-right: 0 !important; + margin-bottom: 16px !important; + } } /* ========================================================================== @@ -1755,8 +1968,8 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */ flex-wrap: nowrap !important; justify-content: center !important; } - .sis-grid-layout.justify-content-center > [class*="sis-grid-span-"], - .sis-grid-layout.justify-content-center > [class*="col-"] { + .sis-grid-layout.justify-content-center > [class*='sis-grid-span-'], + .sis-grid-layout.justify-content-center > [class*='col-'] { flex: 0 0 auto !important; width: auto !important; max-width: none !important; @@ -1819,17 +2032,138 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */ /* Tablet & Desktop grid spans */ @media (min-width: 768px) { - .sis-grid-span-1 { grid-column: span 1 !important; } - .sis-grid-span-2 { grid-column: span 2 !important; } - .sis-grid-span-3 { grid-column: span 3 !important; } - .sis-grid-span-4 { grid-column: span 4 !important; } - .sis-grid-span-5 { grid-column: span 5 !important; } - .sis-grid-span-6 { grid-column: span 6 !important; } - .sis-grid-span-7 { grid-column: span 7 !important; } - .sis-grid-span-8 { grid-column: span 8 !important; } - .sis-grid-span-9 { grid-column: span 9 !important; } - .sis-grid-span-10 { grid-column: span 10 !important; } - .sis-grid-span-11 { grid-column: span 11 !important; } - .sis-grid-span-12 { grid-column: span 12 !important; } + .sis-grid-span-1 { + grid-column: span 1 !important; + } + .sis-grid-span-2 { + grid-column: span 2 !important; + } + .sis-grid-span-3 { + grid-column: span 3 !important; + } + .sis-grid-span-4 { + grid-column: span 4 !important; + } + .sis-grid-span-5 { + grid-column: span 5 !important; + } + .sis-grid-span-6 { + grid-column: span 6 !important; + } + .sis-grid-span-7 { + grid-column: span 7 !important; + } + .sis-grid-span-8 { + grid-column: span 8 !important; + } + .sis-grid-span-9 { + grid-column: span 9 !important; + } + .sis-grid-span-10 { + grid-column: span 10 !important; + } + .sis-grid-span-11 { + grid-column: span 11 !important; + } + .sis-grid-span-12 { + grid-column: span 12 !important; + } } +/* =================================================== + Preserve explicit width/height on images (TinyMCE icons, trophies, etc.) + Overrides global theme img { width: 100% } AND parent wrapper classes (size-5, md:size-8, .relative). + =================================================== */ +[class*='size-'] img[width], +.relative img[width], +img[width]:not([width='100%']):not([width='100']):not([class*='img-fluid']):not([class*='w-100']) { + width: revert !important; + height: auto !important; + inline-size: auto !important; + block-size: auto !important; + max-width: 100% !important; +} + +img[height]:not([height='100%']):not([height='100']):not([class*='img-fluid']):not([class*='w-100']) { + height: revert !important; +} + +img[style*='width']:not([style*='width: 100%']):not([style*='width:100%']):not([style*='width: 100vw']) { + max-width: 100%; + display: inline-block !important; +} + +/* Explicit pixel attribute mapping for images inside constrained wrapper containers */ +img[width='16'], +img[width='16px'] { + width: 16px !important; + height: auto !important; +} +img[width='20'], +img[width='20px'] { + width: 20px !important; + height: auto !important; +} +img[width='24'], +img[width='24px'] { + width: 24px !important; + height: auto !important; +} +img[width='28'], +img[width='28px'] { + width: 28px !important; + height: auto !important; +} +img[width='32'], +img[width='32px'] { + width: 32px !important; + height: auto !important; +} +img[width='36'], +img[width='36px'] { + width: 36px !important; + height: auto !important; +} +img[width='40'], +img[width='40px'] { + width: 40px !important; + height: auto !important; +} +img[width='48'], +img[width='48px'] { + width: 48px !important; + height: auto !important; +} +img[width='50'], +img[width='50px'] { + width: 50px !important; + height: auto !important; +} +img[width='64'], +img[width='64px'] { + width: 64px !important; + height: auto !important; +} +img[width='80'], +img[width='80px'] { + width: 80px !important; + height: auto !important; +} +img[width='96'], +img[width='96px'] { + width: 96px !important; + height: auto !important; +} +img[width='100'], +img[width='100px'] { + width: 100px !important; + height: auto !important; +} + +/* Force No-Wrap for Recruiting Table cells, links, and headers across all devices */ +[class*='sis-recruiting-table'] th, +[class*='sis-recruiting-table'] td, +.sis-recruiting-title-link, +.sis-recruiting-detail-btn { + white-space: nowrap !important; +} diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js index 0e503c7f..1a606877 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js @@ -91,7 +91,7 @@ class SISRawHtmlTool { this.readOnly = readOnly; this.data = { html: (data && data.html) ? data.html : '', - stretched: !!(data && data.stretched) + fullWidth: !!(data && (data.fullWidth || data.stretched)) }; this.textarea = null; this.editorInstance = null; @@ -104,22 +104,22 @@ class SISRawHtmlTool { const stretchWrapper = document.createElement('div'); stretchWrapper.className = 'custom-control custom-switch mb-2'; - this.stretchCheck = document.createElement('input'); - this.stretchCheck.type = 'checkbox'; - this.stretchCheck.className = 'custom-control-input'; - this.stretchCheck.id = 'raw_stretch_' + Math.random().toString(36).substring(7); - this.stretchCheck.checked = !!(this.data && this.data.stretched); + this.fullWidthCheck = document.createElement('input'); + this.fullWidthCheck.type = 'checkbox'; + this.fullWidthCheck.className = 'custom-control-input'; + this.fullWidthCheck.id = 'raw_stretch_' + Math.random().toString(36).substring(7); + this.fullWidthCheck.checked = !!(this.data && this.data.fullWidth); const stretchLabel = document.createElement('label'); stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary'; - stretchLabel.htmlFor = this.stretchCheck.id; + stretchLabel.htmlFor = this.fullWidthCheck.id; stretchLabel.innerHTML = ' Stretch block to Full Screen Width (Independent Breakout)'; - this.stretchCheck.addEventListener('change', () => { - this.data.stretched = this.stretchCheck.checked; + this.fullWidthCheck.addEventListener('change', () => { + this.data.fullWidth = this.fullWidthCheck.checked; }); - stretchWrapper.appendChild(this.stretchCheck); + stretchWrapper.appendChild(this.fullWidthCheck); stretchWrapper.appendChild(stretchLabel); container.appendChild(stretchWrapper); @@ -143,7 +143,7 @@ class SISRawHtmlTool { container.appendChild(this.textarea); if (this.readOnly) { - this.stretchCheck.disabled = true; + this.fullWidthCheck.disabled = true; this.textarea.disabled = true; } @@ -188,7 +188,7 @@ class SISRawHtmlTool { const htmlVal = this.editorInstance ? this.editorInstance.getValue() : (this.textarea ? this.textarea.value : (this.data.html || '')); return { html: htmlVal, - stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched) + fullWidth: this.fullWidthCheck ? this.fullWidthCheck.checked : !!(this.data && this.data.fullWidth) }; } @@ -215,7 +215,7 @@ class SISAccordionTool { title: (data && data.title) ? data.title : '', content: (data && data.content) ? data.content : '', isOpen: !!(data && data.isOpen), - stretched: !!(data && data.stretched) + fullWidth: !!(data && (data.fullWidth || data.stretched)) }; this.titleInput = null; this.contentInput = null; @@ -292,22 +292,22 @@ class SISAccordionTool { // Stretch toggle const stretchWrapper = document.createElement('div'); stretchWrapper.className = 'custom-control custom-switch mt-1'; - this.stretchCheck = document.createElement('input'); - this.stretchCheck.type = 'checkbox'; - this.stretchCheck.className = 'custom-control-input'; - this.stretchCheck.id = 'acc_stretch_' + Math.random().toString(36).substring(7); - this.stretchCheck.checked = !!(this.data && this.data.stretched); + this.fullWidthCheck = document.createElement('input'); + this.fullWidthCheck.type = 'checkbox'; + this.fullWidthCheck.className = 'custom-control-input'; + this.fullWidthCheck.id = 'acc_stretch_' + Math.random().toString(36).substring(7); + this.fullWidthCheck.checked = !!(this.data && this.data.fullWidth); const stretchLabel = document.createElement('label'); stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary'; - stretchLabel.htmlFor = this.stretchCheck.id; + stretchLabel.htmlFor = this.fullWidthCheck.id; stretchLabel.innerHTML = ' Stretch block to Full Screen Width (Independent Breakout)'; - this.stretchCheck.addEventListener('change', () => { - this.data.stretched = this.stretchCheck.checked; + this.fullWidthCheck.addEventListener('change', () => { + this.data.fullWidth = this.fullWidthCheck.checked; }); - stretchWrapper.appendChild(this.stretchCheck); + stretchWrapper.appendChild(this.fullWidthCheck); stretchWrapper.appendChild(stretchLabel); container.appendChild(stretchWrapper); @@ -319,218 +319,12 @@ class SISAccordionTool { title: this.titleInput ? this.titleInput.value : this.data.title, content: this.contentInput ? this.contentInput.value : this.data.content, isOpen: this.openCheck ? this.openCheck.checked : !!(this.data && this.data.isOpen), - stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched) + fullWidth: this.fullWidthCheck ? this.fullWidthCheck.checked : !!(this.data && this.data.fullWidth) }; } } -class SISYouTubeTool { - static get toolbox() { - return { - title: 'YouTube Video', - icon: '' - }; - } - constructor({ data, api, readOnly }) { - this.api = api; - this.readOnly = readOnly; - this.data = { - url: (data && data.url) ? data.url : '', - thumbnailUrl: (data && data.thumbnailUrl) ? data.thumbnailUrl : '', - caption: (data && data.caption) ? data.caption : '', - stretched: !!(data && data.stretched) - }; - this.urlInput = null; - this.thumbnailInput = null; - this.captionInput = null; - this.previewContainer = null; - this.iframe = null; - this.isIframeActive = false; - } - - extractVideoId(input) { - if (!input) return ''; - var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/; - var match = input.match(regExp); - if (match && match[2].length === 11) { - return match[2]; - } - if (input.length === 11) { - return input; - } - return ''; - } - - getEmbedUrl(videoId) { - return videoId ? 'https://www.youtube.com/embed/' + videoId : ''; - } - - getThumbnailUrl(videoId) { - return videoId ? 'https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg' : ''; - } - - render() { - const container = document.createElement('div'); - container.style.border = '1px solid #e3e6f0'; - container.style.borderRadius = '8px'; - container.style.padding = '14px'; - container.style.background = '#fff'; - container.style.marginBottom = '12px'; - - const label = document.createElement('label'); - label.className = 'font-weight-bold text-danger small mb-1'; - label.innerHTML = ' YouTube Video Link / URL / ID'; - - this.urlInput = document.createElement('input'); - this.urlInput.type = 'text'; - this.urlInput.className = 'form-control mb-2'; - this.urlInput.placeholder = 'Paste YouTube URL (e.g. https://www.youtube.com/watch?v=sDE4ZZdNOOY)...'; - this.urlInput.value = this.data.url; - - // Custom Thumbnail Field - const thumbLabel = document.createElement('label'); - thumbLabel.className = 'font-weight-bold text-secondary small mb-1'; - thumbLabel.innerHTML = ' Custom Thumbnail Image URL (Optional - Overrides YouTube Cover)'; - - this.thumbnailInput = document.createElement('input'); - this.thumbnailInput.type = 'text'; - this.thumbnailInput.className = 'form-control form-control-sm mb-2'; - this.thumbnailInput.placeholder = 'Custom Thumbnail Image URL (leave blank to auto-use YouTube cover)...'; - this.thumbnailInput.value = this.data.thumbnailUrl; - - // Thumbnail Preview Container - this.previewContainer = document.createElement('div'); - this.previewContainer.className = 'yt-thumbnail-preview'; - this.previewContainer.style.position = 'relative'; - this.previewContainer.style.paddingBottom = '56.25%'; - this.previewContainer.style.height = '0'; - this.previewContainer.style.overflow = 'hidden'; - this.previewContainer.style.background = '#111 center/cover no-repeat'; - this.previewContainer.style.borderRadius = '6px'; - this.previewContainer.style.marginBottom = '8px'; - this.previewContainer.style.cursor = 'pointer'; - this.previewContainer.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)'; - - // Play Button Overlay - const playBtn = document.createElement('div'); - playBtn.className = 'yt-play-button-overlay'; - playBtn.style.position = 'absolute'; - playBtn.style.top = '50%'; - playBtn.style.left = '50%'; - playBtn.style.transform = 'translate(-50%, -50%)'; - playBtn.style.transition = 'transform 0.2s ease'; - playBtn.innerHTML = ''; - - this.previewContainer.appendChild(playBtn); - - // Update Thumbnail Image - const updatePreview = () => { - const customThumb = this.thumbnailInput.value.trim(); - const videoId = this.extractVideoId(this.urlInput.value); - const thumbUrl = customThumb || this.getThumbnailUrl(videoId); - if (thumbUrl) { - this.previewContainer.style.backgroundImage = 'url("' + thumbUrl + '")'; - playBtn.style.display = 'block'; - } else { - this.previewContainer.style.backgroundImage = 'none'; - this.previewContainer.style.backgroundColor = '#222'; - } - }; - - // Click thumbnail to play live video - this.previewContainer.addEventListener('click', () => { - const videoId = this.extractVideoId(this.urlInput.value); - if (videoId && !this.isIframeActive) { - this.isIframeActive = true; - this.previewContainer.innerHTML = ''; - const iframe = document.createElement('iframe'); - iframe.style.position = 'absolute'; - iframe.style.top = '0'; - iframe.style.left = '0'; - iframe.style.width = '100%'; - iframe.style.height = '100%'; - iframe.style.border = '0'; - iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'); - iframe.setAttribute('allowfullscreen', 'true'); - iframe.src = this.getEmbedUrl(videoId) + '?autoplay=1'; - this.previewContainer.appendChild(iframe); - } - }); - - updatePreview(); - - this.captionInput = document.createElement('input'); - this.captionInput.type = 'text'; - this.captionInput.className = 'form-control form-control-sm mb-2'; - this.captionInput.placeholder = 'Video caption (optional)...'; - this.captionInput.value = this.data.caption; - - if (this.readOnly) { - this.urlInput.disabled = true; - this.thumbnailInput.disabled = true; - this.captionInput.disabled = true; - } - - this.urlInput.addEventListener('input', () => { - const videoId = this.extractVideoId(this.urlInput.value); - this.data.url = this.getEmbedUrl(videoId); - this.isIframeActive = false; - this.previewContainer.innerHTML = ''; - this.previewContainer.appendChild(playBtn); - updatePreview(); - }); - - this.thumbnailInput.addEventListener('input', () => { - this.data.thumbnailUrl = this.thumbnailInput.value.trim(); - updatePreview(); - }); - - this.captionInput.addEventListener('input', () => { - this.data.caption = this.captionInput.value; - }); - - container.appendChild(label); - container.appendChild(this.urlInput); - container.appendChild(thumbLabel); - container.appendChild(this.thumbnailInput); - container.appendChild(this.previewContainer); - container.appendChild(this.captionInput); - - const stretchWrapper = document.createElement('div'); - stretchWrapper.className = 'custom-control custom-switch mt-2'; - this.stretchCheck = document.createElement('input'); - this.stretchCheck.type = 'checkbox'; - this.stretchCheck.className = 'custom-control-input'; - this.stretchCheck.id = 'yt_stretch_' + Math.random().toString(36).substring(7); - this.stretchCheck.checked = !!(this.data && this.data.stretched); - - const stretchLabel = document.createElement('label'); - stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary'; - stretchLabel.htmlFor = this.stretchCheck.id; - stretchLabel.innerHTML = ' Stretch block to Full Screen Width (Independent Breakout)'; - - this.stretchCheck.addEventListener('change', () => { - this.data.stretched = this.stretchCheck.checked; - }); - - stretchWrapper.appendChild(this.stretchCheck); - stretchWrapper.appendChild(stretchLabel); - container.appendChild(stretchWrapper); - - return container; - } - - save() { - const videoId = this.extractVideoId(this.urlInput ? this.urlInput.value : this.data.url); - return { - url: this.getEmbedUrl(videoId) || this.data.url, - thumbnailUrl: this.thumbnailInput ? this.thumbnailInput.value.trim() : (this.data.thumbnailUrl || ''), - caption: this.captionInput ? this.captionInput.value : this.data.caption, - stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched) - }; - } -} // SISHeroBannerTool extracted to /js/manage/editor-plugins/hero-banner.js @@ -613,125 +407,18 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler = if (typeof Marker !== 'undefined') builtInTools.marker = { class: Marker }; if (typeof InlineCode !== 'undefined') builtInTools.inlineCode = { class: InlineCode }; if (typeof Underline !== 'undefined') builtInTools.underline = { class: Underline }; - // Custom Image Block that bypasses default ImageTool uploader and triggers SISMediaPicker directly - class SISCustomImageTool { - static get toolbox() { - return { - title: 'Image', - icon: '' - }; - } - - constructor({ data, api, readOnly }) { - this.api = api; - this.readOnly = readOnly; - this.data = { - file: data.file || { url: '' }, - caption: data.caption || '', - withBorder: !!data.withBorder, - stretched: !!data.stretched, - withBackground: !!data.withBackground - }; - this.container = null; - } - - render() { - this.container = document.createElement('div'); - this.container.className = 'sis-custom-image-block-wrapper position-relative py-2'; - - if (!this.data.file.url) { - // Render placeholder / picker launcher - const trigger = document.createElement('div'); - trigger.className = 'border rounded p-4 text-center cursor-pointer text-muted bg-light d-flex flex-column align-items-center justify-content-center'; - trigger.style.minHeight = '150px'; - trigger.innerHTML = 'Chọn ảnh từ Thư viện (Media Library)'; - trigger.addEventListener('click', () => this._openPicker()); - this.container.appendChild(trigger); - } else { - // Render Image preview along with quick settings button - this._renderPreview(); - } - - return this.container; - } - - _openPicker() { - if (window.SISMediaPicker) { - SISMediaPicker.open((url, mediaObj, config) => { - if (url) { - this.data.file = { - url: url, - style: config ? config.style : '', - cssClass: config ? config.cssClass : '', - alt: config ? config.alt : '', - customAttributes: config ? config.customAttributes : '', - aspectRatio: config ? config.aspectRatio : '' - }; - this.data.caption = (config && config.alt) || ''; - this._renderPreview(); - } - }); - } else { - alert('SISMediaPicker is not loaded.'); - } - } - - _renderPreview() { - if (!this.container) return; - this.container.innerHTML = ''; - - const imgWrapper = document.createElement('div'); - imgWrapper.className = 'position-relative text-center d-inline-block w-100'; - - const img = document.createElement('img'); - img.src = this.data.file.url; - img.className = 'img-fluid rounded shadow-sm'; - if (this.data.file.style) img.style.cssText = this.data.file.style; - if (this.data.file.cssClass) img.className = this.data.file.cssClass; - if (this.data.file.alt) img.alt = this.data.file.alt; - - // Re-configure button - if (!this.readOnly) { - const settingsBtn = document.createElement('button'); - settingsBtn.type = 'button'; - settingsBtn.className = 'btn btn-xs btn-primary shadow-sm position-absolute'; - settingsBtn.style.cssText = 'top: 10px; right: 10px; z-index: 100; font-size: 11px; padding: 2px 8px; border-radius: 4px; opacity: 0.85;'; - settingsBtn.innerHTML = ' Quick settings'; - settingsBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - this._openPicker(); - }); - imgWrapper.appendChild(settingsBtn); - } - - imgWrapper.appendChild(img); - this.container.appendChild(imgWrapper); - - // Caption input underneath image - const captionInput = document.createElement('input'); - captionInput.type = 'text'; - captionInput.className = 'form-control form-control-sm text-center border-0 text-muted mt-2'; - captionInput.placeholder = 'Nhập chú thích hình ảnh (Caption)...'; - captionInput.value = this.data.caption || ''; - captionInput.disabled = this.readOnly; - captionInput.addEventListener('input', (e) => { - this.data.caption = e.target.value; - }); - this.container.appendChild(captionInput); - } - - save(block) { - return this.data; - } - } - - if (typeof ImageTool !== 'undefined' || true) { + if (typeof SISCustomImageTool !== 'undefined' || window.SISCustomImageTool) { + const imageClass = typeof SISCustomImageTool !== 'undefined' ? SISCustomImageTool : window.SISCustomImageTool; builtInTools.image = { - class: SISCustomImageTool + class: imageClass }; window.SISEditorPlugins.image = builtInTools.image; } + + if (typeof SISTabPluginTool !== 'undefined' || window.SISTabPluginTool) { + const toolClass = typeof SISTabPluginTool !== 'undefined' ? SISTabPluginTool : window.SISTabPluginTool; + builtInTools.tabPlugin = { class: toolClass }; + } if (typeof AttachesTool !== 'undefined') { builtInTools.attaches = { class: AttachesTool, @@ -754,7 +441,23 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler = } // === Merge built-in tools with any registered plugins === - var allTools = Object.assign({}, builtInTools, window.SISEditorPlugins); + var sourceTools = Object.assign({}, builtInTools, window.SISEditorPlugins); + var allTools = {}; + var registeredClasses = new Set(); + + for (var key in sourceTools) { + if (Object.prototype.hasOwnProperty.call(sourceTools, key)) { + var toolDef = sourceTools[key]; + var cls = toolDef ? toolDef.class : null; + if (cls && registeredClasses.has(cls)) { + continue; + } + if (cls) { + registeredClasses.add(cls); + } + allTools[key] = toolDef; + } + } // Apply the textStyling tune to all block tools dynamically for (var toolName in allTools) { diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/cms_plugin.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/cms_plugin.js new file mode 100644 index 00000000..b8866577 --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/cms_plugin.js @@ -0,0 +1,116 @@ +/** + * CMS Plugin Shortcode Tool for Editor.js (SIS Vietnam) + * Enables inserting plugin shortcodes like [plugin:price-table] or [plugin:price-table id="table_1"] + */ +class CMSPluginTool { + static get toolbox() { + return { + title: 'CMS Plugin', + icon: '' + }; + } + + constructor({ data, api, readOnly }) { + this.api = api; + this.readOnly = readOnly; + const initialVal = (data && (data.id || data.shortcode)) ? (data.id || data.shortcode) : '[plugin:price-table]'; + this.data = { + id: initialVal, + shortcode: initialVal + }; + this.wrapper = null; + } + + render() { + this.wrapper = document.createElement('div'); + this.wrapper.style.border = '1px solid #4e73df'; + this.wrapper.style.borderRadius = '6px'; + this.wrapper.style.background = '#f8fafc'; + this.wrapper.style.padding = '15px'; + this.wrapper.style.marginBottom = '12px'; + + const header = document.createElement('div'); + header.style.display = 'flex'; + header.style.justifyContent = 'space-between'; + header.style.alignItems = 'center'; + header.style.marginBottom = '10px'; + + const title = document.createElement('label'); + title.className = 'font-weight-bold text-primary m-0'; + title.innerHTML = '🧩 CMS Plugin Shortcode Injection'; + title.style.fontSize = '14px'; + + header.appendChild(title); + this.wrapper.appendChild(header); + + // Preset Quick Select Dropdown + const selectContainer = document.createElement('div'); + selectContainer.style.marginBottom = '10px'; + + const selectLabel = document.createElement('label'); + selectLabel.className = 'small text-muted font-weight-bold'; + selectLabel.innerText = 'Chọn Plugin có sẵn:'; + + const select = document.createElement('select'); + select.className = 'form-control form-control-sm'; + select.style.marginBottom = '8px'; + + select.innerHTML = ` + + + + + + + + `; + + selectContainer.appendChild(selectLabel); + selectContainer.appendChild(select); + this.wrapper.appendChild(selectContainer); + + // Input Field + const inputLabel = document.createElement('label'); + inputLabel.className = 'small text-muted font-weight-bold'; + inputLabel.innerText = 'Mã Shortcode Plugin:'; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'form-control font-weight-bold'; + input.placeholder = 'e.g. [plugin:price-table id="table_1"]'; + input.value = this.data.shortcode || this.data.id || ''; + if (this.readOnly) input.disabled = true; + + select.addEventListener('change', (e) => { + const val = e.target.value; + if (val && val !== 'custom') { + input.value = val; + this.data.shortcode = val; + this.data.id = val; + } + }); + + input.addEventListener('input', (e) => { + this.data.shortcode = e.target.value.trim(); + this.data.id = e.target.value.trim(); + }); + + this.wrapper.appendChild(inputLabel); + this.wrapper.appendChild(input); + + return this.wrapper; + } + + save() { + return { + id: this.data.id || this.data.shortcode || '', + shortcode: this.data.shortcode || this.data.id || '' + }; + } +} + +// Register the plugin globally +window.SISEditorPlugins = window.SISEditorPlugins || {}; +window.SISEditorPlugins['cmsPlugin'] = { + class: CMSPluginTool +}; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/custom-image.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/custom-image.js new file mode 100644 index 00000000..783b687f --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/custom-image.js @@ -0,0 +1,126 @@ +/** + * Custom Image Tool for Editor.js (SIS Vietnam) + * Directly integrates with SISMediaPicker for easy image selection and quick configuration. + */ +class SISCustomImageTool { + static get toolbox() { + return { + title: 'Image', + icon: '' + }; + } + + constructor({ data, api, readOnly }) { + this.api = api; + this.readOnly = readOnly; + this.data = { + file: data.file || { url: '' }, + caption: data.caption || '', + withBorder: !!data.withBorder, + stretched: !!data.stretched, + withBackground: !!data.withBackground + }; + this.container = null; + } + + render() { + this.container = document.createElement('div'); + this.container.className = 'sis-custom-image-block-wrapper position-relative py-2'; + + if (!this.data.file.url) { + // Render placeholder / picker launcher + const trigger = document.createElement('div'); + trigger.className = 'border rounded p-4 text-center cursor-pointer text-muted bg-light d-flex flex-column align-items-center justify-content-center'; + trigger.style.minHeight = '150px'; + trigger.innerHTML = 'Chọn ảnh từ Thư viện (Media Library)'; + trigger.addEventListener('click', () => this._openPicker()); + this.container.appendChild(trigger); + } else { + // Render Image preview along with quick settings button + this._renderPreview(); + } + + return this.container; + } + + _openPicker() { + if (window.SISMediaPicker) { + SISMediaPicker.open((url, mediaObj, config) => { + if (url) { + const imgId = config ? (config.id || config.elementId || '') : ''; + this.data.file = { + url: url, + id: imgId, + elementId: imgId, + style: config ? config.style : '', + cssClass: config ? config.cssClass : '', + alt: config ? config.alt : '', + customAttributes: config ? config.customAttributes : '', + aspectRatio: config ? config.aspectRatio : '' + }; + this.data.caption = (config && config.alt) || ''; + this._renderPreview(); + } + }, this.data.file ? this.data.file.url : '', this.data.file || {}); + } else { + alert('SISMediaPicker is not loaded.'); + } + } + + _renderPreview() { + if (!this.container) return; + this.container.innerHTML = ''; + + const imgWrapper = document.createElement('div'); + imgWrapper.className = 'position-relative text-center d-inline-block w-100'; + + const img = document.createElement('img'); + img.src = this.data.file.url; + img.className = 'img-fluid rounded shadow-sm'; + if (this.data.file.id || this.data.file.elementId) img.id = this.data.file.id || this.data.file.elementId; + if (this.data.file.style) img.style.cssText = this.data.file.style; + if (this.data.file.cssClass) img.className = this.data.file.cssClass; + if (this.data.file.alt) img.alt = this.data.file.alt; + + // Re-configure button + if (!this.readOnly) { + const settingsBtn = document.createElement('button'); + settingsBtn.type = 'button'; + settingsBtn.className = 'btn btn-xs btn-primary shadow-sm position-absolute'; + settingsBtn.style.cssText = 'top: 10px; right: 10px; z-index: 100; font-size: 11px; padding: 2px 8px; border-radius: 4px; opacity: 0.85;'; + settingsBtn.innerHTML = ' Quick settings'; + settingsBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this._openPicker(); + }); + imgWrapper.appendChild(settingsBtn); + } + + imgWrapper.appendChild(img); + this.container.appendChild(imgWrapper); + + // Caption input underneath image + const captionInput = document.createElement('input'); + captionInput.type = 'text'; + captionInput.className = 'form-control form-control-sm text-center border-0 text-muted mt-2'; + captionInput.placeholder = 'Nhập chú thích hình ảnh (Caption)...'; + captionInput.value = this.data.caption || ''; + captionInput.disabled = this.readOnly; + captionInput.addEventListener('input', (e) => { + this.data.caption = e.target.value; + }); + this.container.appendChild(captionInput); + } + + save(block) { + return this.data; + } +} + +// Register globally +window.SISCustomImageTool = SISCustomImageTool; +window.SISEditorPlugins = window.SISEditorPlugins || {}; +window.SISEditorPlugins['image'] = { + class: SISCustomImageTool +}; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/flex.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/flex.js index 7999e67f..d83b40c5 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/flex.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/flex.js @@ -161,15 +161,21 @@ class FlexTool { // 1. Flex Items (Cols) const colDiv = document.createElement('div'); - colDiv.className = 'col-md-2 mb-2'; + colDiv.className = 'col-md-3 mb-2'; colDiv.innerHTML = ''; + + const colControlGroup = document.createElement('div'); + colControlGroup.className = 'd-flex align-items-center'; + const input = document.createElement('input'); input.type = 'number'; - input.className = 'form-control form-control-sm'; + input.className = 'form-control form-control-sm mr-1'; + input.style.width = '65px'; input.min = '1'; input.max = '12'; input.value = this.data.cols; if (this.readOnly) input.disabled = true; + this.colsInput = input; input.addEventListener('input', (e) => { let val = parseInt(e.target.value) || 2; if (val < 1) val = 1; @@ -177,7 +183,18 @@ class FlexTool { this.data.cols = val; this._renderColumnInputs(); }); - colDiv.appendChild(input); + colControlGroup.appendChild(input); + + const addColBtn = document.createElement('button'); + addColBtn.type = 'button'; + addColBtn.className = 'btn btn-sm btn-outline-primary font-weight-bold'; + addColBtn.title = 'Add new Flex Item'; + addColBtn.innerHTML = ' + Item'; + if (this.readOnly) addColBtn.disabled = true; + addColBtn.addEventListener('click', () => this._addItem(this.data.cols)); + colControlGroup.appendChild(addColBtn); + + colDiv.appendChild(colControlGroup); bgSettingsRow.appendChild(colDiv); // 2. Global CSS Class @@ -212,12 +229,12 @@ class FlexTool { // 4. Global Custom Attributes const attrDiv = document.createElement('div'); - attrDiv.className = 'col-md-4 mb-2'; + attrDiv.className = 'col-md-3 mb-2'; attrDiv.innerHTML = ''; const attrInput = document.createElement('input'); attrInput.type = 'text'; attrInput.className = 'form-control form-control-sm'; - attrInput.placeholder = 'e.g. data-aos="fade-up" style="background:#fff"'; + attrInput.placeholder = 'e.g. data-aos="fade-up"'; attrInput.value = this.data.globalAttributes || ''; if (this.readOnly) attrInput.disabled = true; attrInput.addEventListener('input', (e) => this.data.globalAttributes = e.target.value.trim()); @@ -236,6 +253,277 @@ class FlexTool { return this.wrapper; } + _getItemData(i) { + return { + type: this.data[`type${i}`] || 'html', + col: this.data[`col${i}`] || '', + imgUrl: this.data[`imgUrl${i}`] || '', + ytUrl: this.data[`ytUrl${i}`] || '', + ytThumbUrl: this.data[`ytThumbUrl${i}`] || '', + ytStretched: !!this.data[`ytStretched${i}`], + accTitle: this.data[`accTitle${i}`] || '', + accContent: this.data[`accContent${i}`] || '', + class: this.data[`class${i}`] || '', + id: this.data[`id${i}`] || '', + attr: this.data[`attr${i}`] || '', + width: this.data[`width${i}`] || 0, + caption: this.data[`caption${i}`] || '', + subType: this.data[`subType${i}`] || 'none', + colsub_: this.data[`colsub_${i}`] || '', + imgUrlsub_: this.data[`imgUrlsub_${i}`] || '', + ytUrlsub_: this.data[`ytUrlsub_${i}`] || '', + ytThumbUrlsub_: this.data[`ytThumbUrlsub_${i}`] || '', + ytStretchedsub_: !!this.data[`ytStretchedsub_${i}`], + accTitlesub_: this.data[`accTitlesub_${i}`] || '', + accContentsub_: this.data[`accContentsub_${i}`] || '', + classsub_: this.data[`classsub_${i}`] || '', + idsub_: this.data[`idsub_${i}`] || '', + attrsub_: this.data[`attrsub_${i}`] || '' + }; + } + + _setItemData(i, obj) { + this.data[`type${i}`] = obj.type || 'html'; + let colVal = obj.col !== undefined && obj.col !== null ? obj.col : ''; + if (typeof colVal === 'object') { + colVal = JSON.stringify(colVal); + } + this.data[`col${i}`] = colVal; + this.data[`imgUrl${i}`] = obj.imgUrl || ''; + this.data[`ytUrl${i}`] = obj.ytUrl || ''; + this.data[`ytThumbUrl${i}`] = obj.ytThumbUrl || obj.imgUrl || ''; + this.data[`ytStretched${i}`] = !!obj.ytStretched; + this.data[`accTitle${i}`] = obj.accTitle || ''; + this.data[`accContent${i}`] = obj.accContent || ''; + this.data[`class${i}`] = obj.class || ''; + this.data[`id${i}`] = obj.id || ''; + this.data[`attr${i}`] = obj.attr || ''; + this.data[`width${i}`] = obj.width || 0; + this.data[`caption${i}`] = obj.caption || ''; + this.data[`subType${i}`] = obj.subType || 'none'; + let colsubVal = obj.colsub_ !== undefined && obj.colsub_ !== null ? obj.colsub_ : ''; + if (typeof colsubVal === 'object') { + colsubVal = JSON.stringify(colsubVal); + } + this.data[`colsub_${i}`] = colsubVal; + this.data[`imgUrlsub_${i}`] = obj.imgUrlsub_ || ''; + this.data[`ytUrlsub_${i}`] = obj.ytUrlsub_ || ''; + this.data[`ytThumbUrlsub_${i}`] = obj.ytThumbUrlsub_ || obj.imgUrlsub_ || ''; + this.data[`ytStretchedsub_${i}`] = !!obj.ytStretchedsub_; + this.data[`accTitlesub_${i}`] = obj.accTitlesub_ || ''; + this.data[`accContentsub_${i}`] = obj.accContentsub_ || ''; + this.data[`classsub_${i}`] = obj.classsub_ || ''; + this.data[`idsub_${i}`] = obj.idsub_ || ''; + this.data[`attrsub_${i}`] = obj.attrsub_ || ''; + } + + _swapItems(i, j) { + const itemI = this._getItemData(i); + const itemJ = this._getItemData(j); + this._setItemData(i, itemJ); + this._setItemData(j, itemI); + this._renderColumnInputs(); + } + + _addItem(afterIdx) { + if (this.data.cols >= 12) return; + const newCols = this.data.cols + 1; + for (let i = newCols; i > afterIdx + 1; i--) { + this._setItemData(i, this._getItemData(i - 1)); + } + this._setItemData(afterIdx + 1, { type: 'html', col: '', subType: 'none' }); + this.data.cols = newCols; + if (this.colsInput) this.colsInput.value = newCols; + this._renderColumnInputs(); + } + + _deleteItem(idx) { + if (this.data.cols <= 1) return; + for (let i = idx; i < this.data.cols; i++) { + this._setItemData(i, this._getItemData(i + 1)); + } + this._setItemData(this.data.cols, { type: 'html', col: '', subType: 'none' }); + this.data.cols = this.data.cols - 1; + if (this.colsInput) this.colsInput.value = this.data.cols; + this._renderColumnInputs(); + } + + _showItem6DotsMenu(i, dragIcon) { + document.querySelectorAll('.sis-item-6dots-menu').forEach(m => m.remove()); + + const menu = document.createElement('div'); + menu.className = 'sis-item-6dots-menu bg-white border rounded shadow-lg p-2'; + menu.style.position = 'absolute'; + menu.style.zIndex = '999999'; + menu.style.minWidth = '220px'; + menu.style.fontSize = '12px'; + menu.style.boxShadow = '0 8px 24px rgba(0,0,0,0.18)'; + + const rect = dragIcon.getBoundingClientRect(); + menu.style.top = (rect.bottom + window.scrollY + 4) + 'px'; + menu.style.left = (rect.left + window.scrollX) + 'px'; + + const header = document.createElement('div'); + header.className = 'font-weight-bold text-primary mb-2 pb-1 border-bottom d-flex justify-content-between align-items-center'; + header.innerHTML = ` Tùy chọn Item ${i}×`; + header.querySelector('.close-btn').addEventListener('click', () => menu.remove()); + menu.appendChild(header); + + // 1. Copy Item + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'btn btn-sm btn-light btn-block text-left mb-1'; + copyBtn.style.fontSize = '12px'; + copyBtn.innerHTML = ' Sao chép Item này (Copy)'; + copyBtn.addEventListener('click', () => { + const itemData = this._getItemData(i); + const jsonStr = JSON.stringify(itemData); + localStorage.setItem('sis_copied_item_data', jsonStr); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(jsonStr).catch(() => {}); + } + copyBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1'; + copyBtn.innerHTML = ' Đã sao chép Item!'; + setTimeout(() => menu.remove(), 800); + }); + menu.appendChild(copyBtn); + + // 2. Unified Paste (Handles both inner Item copy and Editor.js Block copy) + let dataToPaste = null; + const copiedRawItem = localStorage.getItem('sis_copied_item_data'); + const copiedRawBlock = localStorage.getItem('sis_copied_editor_block'); + + if (copiedRawItem) { + try { + dataToPaste = JSON.parse(copiedRawItem); + } catch (e) {} + } else if (copiedRawBlock) { + try { + const b = JSON.parse(copiedRawBlock); + if (b && b.type) { + dataToPaste = { + type: b.type, + col: JSON.stringify(b.data || {}), + subType: 'none', + class: '', + id: '', + attr: '', + width: 0 + }; + } + } catch (e) {} + } + + if (dataToPaste) { + // 2a. Paste into current item (overwrite) + const pasteBtn = document.createElement('button'); + pasteBtn.type = 'button'; + pasteBtn.className = 'btn btn-sm btn-light btn-block text-left mb-1'; + pasteBtn.style.fontSize = '12px'; + pasteBtn.innerHTML = ' Dán vào Item này (ghi đè)'; + pasteBtn.addEventListener('click', () => { + this._setItemData(i, dataToPaste); + pasteBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1'; + pasteBtn.innerHTML = ' Đã dán Item!'; + setTimeout(() => { + menu.remove(); + this._renderColumnInputs(); + }, 600); + }); + menu.appendChild(pasteBtn); + + // 2b. Paste as new item inserted after current + const pasteNewBtn = document.createElement('button'); + pasteNewBtn.type = 'button'; + pasteNewBtn.className = 'btn btn-sm btn-light btn-block text-left mb-1'; + pasteNewBtn.style.fontSize = '12px'; + pasteNewBtn.innerHTML = ' Dán thêm Item mới phía sau'; + if (this.data.cols >= 12) pasteNewBtn.disabled = true; + pasteNewBtn.addEventListener('click', () => { + if (this.data.cols >= 12) return; + const newCols = this.data.cols + 1; + for (let k = newCols; k > i + 1; k--) { + this._setItemData(k, this._getItemData(k - 1)); + } + this._setItemData(i + 1, dataToPaste); + this.data.cols = newCols; + if (this.colsInput) this.colsInput.value = newCols; + pasteNewBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1'; + pasteNewBtn.innerHTML = ' Đã thêm Item mới!'; + setTimeout(() => { + menu.remove(); + this._renderColumnInputs(); + }, 600); + }); + menu.appendChild(pasteNewBtn); + } + + // 3. Move Left / Move Right + const moveGroup = document.createElement('div'); + moveGroup.className = 'd-flex justify-content-between mb-1'; + + const moveLeft = document.createElement('button'); + moveLeft.type = 'button'; + moveLeft.className = 'btn btn-xs btn-outline-secondary flex-fill mr-1 py-1'; + moveLeft.style.fontSize = '11px'; + moveLeft.innerHTML = ' Sang trái'; + if (i <= 1) moveLeft.disabled = true; + moveLeft.addEventListener('click', () => { + this._swapItems(i, i - 1); + menu.remove(); + }); + moveGroup.appendChild(moveLeft); + + const moveRight = document.createElement('button'); + moveRight.type = 'button'; + moveRight.className = 'btn btn-xs btn-outline-secondary flex-fill ml-1 py-1'; + moveRight.style.fontSize = '11px'; + moveRight.innerHTML = 'Sang phải '; + if (i >= this.data.cols) moveRight.disabled = true; + moveRight.addEventListener('click', () => { + this._swapItems(i, i + 1); + menu.remove(); + }); + moveGroup.appendChild(moveRight); + menu.appendChild(moveGroup); + + // 4. Add Item After + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'btn btn-sm btn-light btn-block text-left text-success mb-1'; + addBtn.style.fontSize = '12px'; + addBtn.innerHTML = ' Thêm Item mới phía sau'; + if (this.data.cols >= 12) addBtn.disabled = true; + addBtn.addEventListener('click', () => { + this._addItem(i); + menu.remove(); + }); + menu.appendChild(addBtn); + + // 5. Delete Item + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'btn btn-sm btn-light btn-block text-left text-danger mb-0'; + delBtn.style.fontSize = '12px'; + delBtn.innerHTML = ' Xóa Item này'; + if (this.data.cols <= 1) delBtn.disabled = true; + delBtn.addEventListener('click', () => { + this._deleteItem(i); + menu.remove(); + }); + menu.appendChild(delBtn); + + document.body.appendChild(menu); + + const closeHandler = (evt) => { + if (!menu.contains(evt.target) && !dragIcon.contains(evt.target)) { + menu.remove(); + document.removeEventListener('click', closeHandler); + } + }; + setTimeout(() => document.addEventListener('click', closeHandler), 50); + } + _renderColumnInputs() { this.inputsContainer.innerHTML = ''; @@ -247,16 +535,108 @@ class FlexTool { for (let i = 1; i <= count; i++) { const colDiv = document.createElement('div'); - colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white`; + colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white ce-flex-item-card d-flex flex-column justify-content-between`; colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)'; + colDiv.style.transition = 'border-color 0.2s, box-shadow 0.2s'; + colDiv.draggable = true; + + // HTML5 Drag & Drop handlers + colDiv.addEventListener('dragstart', (e) => { + e.dataTransfer.setData('text/plain', i); + colDiv.style.opacity = '0.5'; + }); + colDiv.addEventListener('dragend', () => { + colDiv.style.opacity = '1'; + }); + colDiv.addEventListener('dragover', (e) => { + e.preventDefault(); + colDiv.style.borderColor = '#007bff'; + colDiv.style.boxShadow = '0 0 0 2px rgba(0, 123, 255, 0.25)'; + }); + colDiv.addEventListener('dragleave', () => { + colDiv.style.borderColor = ''; + colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)'; + }); + colDiv.addEventListener('drop', (e) => { + e.preventDefault(); + colDiv.style.borderColor = ''; + colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)'; + const fromIdx = parseInt(e.dataTransfer.getData('text/plain')); + if (fromIdx && fromIdx !== i) { + this._swapItems(fromIdx, i); + } + }); const headerRow = document.createElement('div'); headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom'; + // Left Header: 6-dot drag icon, Item Label, Move Left/Right, Add Item (+), Delete (x) + const labelWrapper = document.createElement('div'); + labelWrapper.className = 'd-flex align-items-center'; + + // 6-dot Drag Handle Icon + const dragIcon = document.createElement('span'); + dragIcon.className = 'mr-2 text-muted sis-drag-handle-dots'; + dragIcon.style.cursor = 'grab'; + dragIcon.style.userSelect = 'none'; + dragIcon.style.display = 'inline-flex'; + dragIcon.style.alignItems = 'center'; + dragIcon.title = 'Kéo để sắp xếp, hoặc Click để mở menu chức năng đầy đủ'; + dragIcon.innerHTML = ``; + dragIcon.addEventListener('click', (e) => { + e.stopPropagation(); + this._showItem6DotsMenu(i, dragIcon); + }); + const label = document.createElement('label'); - label.className = 'small font-weight-bold text-dark mb-0'; + label.className = 'small font-weight-bold text-dark mb-0 mr-2'; label.innerText = `Item ${i}`; - headerRow.appendChild(label); + + // Controls: Move Left (◀), Move Right (▶), + Item, Delete (✕) + const moveLeftBtn = document.createElement('button'); + moveLeftBtn.type = 'button'; + moveLeftBtn.className = 'btn btn-xs btn-light border text-muted mr-1 px-1 py-0'; + moveLeftBtn.style.fontSize = '10px'; + moveLeftBtn.title = 'Move Left / Sang trái'; + moveLeftBtn.innerText = '◀'; + if (i === 1 || this.readOnly) moveLeftBtn.disabled = true; + moveLeftBtn.addEventListener('click', () => this._swapItems(i, i - 1)); + + const moveRightBtn = document.createElement('button'); + moveRightBtn.type = 'button'; + moveRightBtn.className = 'btn btn-xs btn-light border text-muted mr-1 px-1 py-0'; + moveRightBtn.style.fontSize = '10px'; + moveRightBtn.title = 'Move Right / Sang phải'; + moveRightBtn.innerText = '▶'; + if (i === count || this.readOnly) moveRightBtn.disabled = true; + moveRightBtn.addEventListener('click', () => this._swapItems(i, i + 1)); + + const addItemBtn = document.createElement('button'); + addItemBtn.type = 'button'; + addItemBtn.className = 'btn btn-xs btn-outline-success mr-1 px-1 py-0 font-weight-bold'; + addItemBtn.style.fontSize = '10px'; + addItemBtn.title = 'Insert new item after this / Thêm item mới'; + addItemBtn.innerText = '+ Item'; + if (count >= 12 || this.readOnly) addItemBtn.disabled = true; + addItemBtn.addEventListener('click', () => this._addItem(i)); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'btn btn-xs btn-outline-danger px-1 py-0 font-weight-bold'; + deleteBtn.style.fontSize = '10px'; + deleteBtn.title = 'Delete item / Xóa item'; + deleteBtn.innerText = '✕'; + if (count <= 1 || this.readOnly) deleteBtn.disabled = true; + deleteBtn.addEventListener('click', () => this._deleteItem(i)); + + labelWrapper.appendChild(dragIcon); + labelWrapper.appendChild(label); + labelWrapper.appendChild(moveLeftBtn); + labelWrapper.appendChild(moveRightBtn); + labelWrapper.appendChild(addItemBtn); + labelWrapper.appendChild(deleteBtn); + + headerRow.appendChild(labelWrapper); const controlsWrapper = document.createElement('div'); controlsWrapper.className = 'd-flex align-items-center gap-1'; @@ -311,8 +691,12 @@ class FlexTool { ]; if (window.SISEditorPlugins) { + const seenPlugins = new Set(); Object.keys(window.SISEditorPlugins).forEach(key => { - // Allow nesting any registered plugins (no exclusions) + const entry = window.SISEditorPlugins[key]; + const pluginObj = entry ? (entry.class || entry) : null; + if (pluginObj && seenPlugins.has(pluginObj)) return; + if (pluginObj) seenPlugins.add(pluginObj); if (!types.some(t => t.value === key)) { types.push({ value: key, label: `[Plugin] ${key}` }); } @@ -328,6 +712,7 @@ class FlexTool { }); const contentDiv = document.createElement('div'); + contentDiv.className = 'd-flex flex-column flex-fill'; typeSelect.addEventListener('change', (e) => { this.data[`type${i}`] = e.target.value; @@ -369,7 +754,12 @@ class FlexTool { { value: 'accordion', label: 'Accordion' } ]; if (window.SISEditorPlugins) { + const seenSubPlugins = new Set(); Object.keys(window.SISEditorPlugins).forEach(key => { + const entry = window.SISEditorPlugins[key]; + const pluginObj = entry ? (entry.class || entry) : null; + if (pluginObj && seenSubPlugins.has(pluginObj)) return; + if (pluginObj) seenSubPlugins.add(pluginObj); if (!subTypes.some(t => t.value === key)) { subTypes.push({ value: key, label: `[Plugin] ${key}` }); } @@ -432,6 +822,18 @@ class FlexTool { renderSubInputs(); } + if (count < 12 && !this.readOnly) { + const addCardCol = document.createElement('div'); + addCardCol.className = 'col-12 mt-1 text-center'; + const addCardBtn = document.createElement('button'); + addCardBtn.type = 'button'; + addCardBtn.className = 'btn btn-sm btn-outline-primary font-weight-bold px-3 py-1 shadow-sm'; + addCardBtn.innerHTML = ' + Thêm Item Mới'; + addCardBtn.addEventListener('click', () => this._addItem(count)); + addCardCol.appendChild(addCardBtn); + row.appendChild(addCardCol); + } + this.inputsContainer.appendChild(row); } @@ -444,7 +846,7 @@ class FlexTool { } const typeContainer = document.createElement('div'); - typeContainer.className = 'mb-3'; + typeContainer.className = 'mb-3 d-flex flex-column flex-fill'; container.appendChild(typeContainer); const type = explicitType || this.data[`type${i}`] || 'html'; @@ -543,10 +945,26 @@ class FlexTool { e.preventDefault(); e.stopPropagation(); if (window.SISMediaPicker) { + const currentUrl = (this.data[`col${i}`] && this.data[`col${i}`].file) ? this.data[`col${i}`].file.url : (this.data[`imgUrl${i}`] || ''); + const currentConfig = { + style: this.data[`imgStyle${i}`] || '', + cssClass: this.data[`imgClass${i}`] || '', + alt: this.data[`imgAlt${i}`] || '', + customAttributes: this.data[`imgAttrs${i}`] || '', + aspectRatio: this.data[`imgAspectRatio${i}`] || '' + }; SISMediaPicker.open((url, mediaObj, config) => { if (url) { + const fileObj = { + url: url, + style: (config && config.style) || '', + cssClass: (config && config.cssClass) || '', + alt: (config && config.alt) || '', + customAttributes: (config && config.customAttributes) || '', + aspectRatio: (config && config.aspectRatio) || '' + }; this.data[`col${i}`] = { - file: { url: url }, + file: fileObj, caption: (config && config.alt) || '', withBorder: false, withBackground: false, @@ -561,7 +979,7 @@ class FlexTool { } this._renderTypeSpecificInput(i, container); } - }); + }, currentUrl, currentConfig); } else { alert('Media Library is not yet configured on this system.'); } @@ -572,11 +990,13 @@ class FlexTool { if (clearBtn) { clearBtn.addEventListener('click', (e) => { e.preventDefault(); - e.stopPropagation(); - if (confirm('Are you sure you want to remove this image?')) { - this.data[`col${i}`] = ''; - this._renderTypeSpecificInput(i, container); - } + delete this.data[`col${i}`]; + delete this.data[`imgStyle${i}`]; + delete this.data[`imgClass${i}`]; + delete this.data[`imgAlt${i}`]; + delete this.data[`imgAttrs${i}`]; + delete this.data[`imgAspectRatio${i}`]; + this._renderTypeSpecificInput(i, container); }); } @@ -643,6 +1063,14 @@ class FlexTool { chooseBtn.innerText = 'Choose'; if (this.readOnly) chooseBtn.disabled = true; chooseBtn.addEventListener('click', () => { + const currentUrl = this.data[`imgUrl${i}`] || ''; + const currentConfig = { + style: this.data[`imgStyle${i}`] || '', + cssClass: this.data[`imgClass${i}`] || '', + alt: this.data[`imgAlt${i}`] || '', + customAttributes: this.data[`imgAttrs${i}`] || '', + aspectRatio: this.data[`imgAspectRatio${i}`] || '' + }; if (window.SISMediaPicker) { SISMediaPicker.open((selectedUrl, mediaObj, config) => { input.value = selectedUrl; @@ -656,7 +1084,7 @@ class FlexTool { } previewImg.src = selectedUrl; previewBox.style.display = 'block'; - }); + }, currentUrl, currentConfig); } else { alert('Media Picker modal helper is not loaded.'); } @@ -742,23 +1170,135 @@ class FlexTool { }); } else if (type === 'youtube') { + const label = document.createElement('label'); + label.className = 'font-weight-bold text-danger small mb-1'; + label.innerHTML = ' Đường dẫn YouTube Video (URL / ID)'; + const input = document.createElement('input'); input.type = 'text'; input.className = 'form-control form-control-sm mb-2'; - input.placeholder = 'Paste YouTube video URL...'; + input.placeholder = 'Paste YouTube video URL (e.g. https://www.youtube.com/watch?v=sDE4ZZdNOOY)...'; input.value = this.data[`ytUrl${i}`] || ''; if (this.readOnly) input.disabled = true; - const preview = document.createElement('div'); - preview.className = 'small text-muted p-2 bg-light border rounded'; - preview.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX'; + // Custom Thumbnail Group + const thumbLabel = document.createElement('label'); + thumbLabel.className = 'font-weight-bold text-secondary small mb-1'; + thumbLabel.innerHTML = ' Ảnh bìa Custom Thumbnail (Tùy chọn - Đè lên bìa YouTube)'; + + const thumbGroup = document.createElement('div'); + thumbGroup.className = 'input-group input-group-sm mb-2'; + + const thumbInput = document.createElement('input'); + thumbInput.type = 'text'; + thumbInput.className = 'form-control'; + thumbInput.placeholder = 'URL ảnh bìa hoặc chọn từ Thư viện Media...'; + thumbInput.value = this.data[`ytThumbUrl${i}`] || this.data[`imgUrl${i}`] || ''; + if (this.readOnly) thumbInput.disabled = true; + + const thumbAppend = document.createElement('div'); + thumbAppend.className = 'input-group-append'; + + const mediaBtn = document.createElement('button'); + mediaBtn.type = 'button'; + mediaBtn.className = 'btn btn-outline-primary font-weight-bold'; + mediaBtn.innerHTML = ' Thư viện Media'; + if (this.readOnly) mediaBtn.disabled = true; + + mediaBtn.addEventListener('click', (e) => { + e.preventDefault(); + if (window.SISMediaPicker) { + SISMediaPicker.open((selectedUrl) => { + thumbInput.value = selectedUrl; + this.data[`ytThumbUrl${i}`] = selectedUrl; + this.data[`imgUrl${i}`] = selectedUrl; + updatePreview(); + }); + } else { + alert('Thư viện Media (SISMediaPicker) chưa được tải.'); + } + }); + + thumbAppend.appendChild(mediaBtn); + thumbGroup.appendChild(thumbInput); + thumbGroup.appendChild(thumbAppend); + + // Stretch / Full-width Switch + const stretchWrapper = document.createElement('div'); + stretchWrapper.className = 'custom-control custom-switch mb-2'; + + const stretchCheck = document.createElement('input'); + stretchCheck.type = 'checkbox'; + stretchCheck.className = 'custom-control-input'; + const switchId = 'yt_item_stretch_' + i + '_' + Math.random().toString(36).substring(7); + stretchCheck.id = switchId; + stretchCheck.checked = !!(this.data[`ytStretched${i}`]); + if (this.readOnly) stretchCheck.disabled = true; + + const stretchLabel = document.createElement('label'); + stretchLabel.className = 'custom-control-label small font-weight-bold text-dark'; + stretchLabel.htmlFor = switchId; + stretchLabel.style.cursor = 'pointer'; + stretchLabel.innerHTML = ' Ép full chiều rộng (Force Full Width)'; + + stretchCheck.addEventListener('change', () => { + this.data[`ytStretched${i}`] = stretchCheck.checked; + }); + + stretchWrapper.appendChild(stretchCheck); + stretchWrapper.appendChild(stretchLabel); + + // Thumbnail Preview Box + const previewBox = document.createElement('div'); + previewBox.className = 'yt-preview-card p-2 bg-light border rounded text-center mb-2'; + previewBox.style.minHeight = '60px'; + previewBox.style.borderRadius = '6px'; + previewBox.style.background = '#111 center/cover no-repeat'; + previewBox.style.position = 'relative'; + + const updatePreview = () => { + const url = input.value.trim(); + const customThumb = thumbInput.value.trim(); + let videoId = ''; + var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/; + var match = url.match(regExp); + if (match && match[2].length === 11) videoId = match[2]; + else if (url.length === 11) videoId = url; + + const thumbUrl = customThumb || (videoId ? 'https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg' : ''); + if (thumbUrl) { + previewBox.style.backgroundImage = 'url("' + thumbUrl + '")'; + previewBox.style.paddingBottom = '56.25%'; + previewBox.style.height = '0'; + previewBox.style.display = 'block'; + } else { + previewBox.style.backgroundImage = 'none'; + previewBox.style.paddingBottom = '0'; + previewBox.style.height = 'auto'; + previewBox.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX'; + } + }; input.addEventListener('input', (e) => { this.data[`ytUrl${i}`] = e.target.value.trim(); + updatePreview(); }); + thumbInput.addEventListener('input', (e) => { + const val = e.target.value.trim(); + this.data[`ytThumbUrl${i}`] = val; + this.data[`imgUrl${i}`] = val; + updatePreview(); + }); + + updatePreview(); + + typeContainer.appendChild(label); typeContainer.appendChild(input); - typeContainer.appendChild(preview); + typeContainer.appendChild(thumbLabel); + typeContainer.appendChild(thumbGroup); + typeContainer.appendChild(stretchWrapper); + typeContainer.appendChild(previewBox); } else if (type === 'accordion') { const titleInput = document.createElement('input'); @@ -861,7 +1401,14 @@ class FlexTool { } savedData[`imgUrl${i}`] = this.data[`imgUrl${i}`] || ''; + savedData[`imgId${i}`] = this.data[`imgId${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? (this.data[`col${i}`].file.id || this.data[`col${i}`].file.elementId) : ''); + savedData[`imgStyle${i}`] = this.data[`imgStyle${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.style : ''); + savedData[`imgClass${i}`] = this.data[`imgClass${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.cssClass : ''); + savedData[`imgAlt${i}`] = this.data[`imgAlt${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.alt : ''); + savedData[`imgAttrs${i}`] = this.data[`imgAttrs${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.customAttributes : ''); savedData[`ytUrl${i}`] = this.data[`ytUrl${i}`] || ''; + savedData[`ytThumbUrl${i}`] = this.data[`ytThumbUrl${i}`] || ''; + savedData[`ytStretched${i}`] = !!this.data[`ytStretched${i}`]; savedData[`accTitle${i}`] = this.data[`accTitle${i}`] || ''; savedData[`accContent${i}`] = this.data[`accContent${i}`] || ''; savedData[`class${i}`] = this.data[`class${i}`] || ''; @@ -888,6 +1435,8 @@ class FlexTool { savedData[`imgUrlsub_${i}`] = this.data[`imgUrlsub_${i}`] || ''; savedData[`ytUrlsub_${i}`] = this.data[`ytUrlsub_${i}`] || ''; + savedData[`ytThumbUrlsub_${i}`] = this.data[`ytThumbUrlsub_${i}`] || ''; + savedData[`ytStretchedsub_${i}`] = !!this.data[`ytStretchedsub_${i}`]; savedData[`accTitlesub_${i}`] = this.data[`accTitlesub_${i}`] || ''; savedData[`accContentsub_${i}`] = this.data[`accContentsub_${i}`] || ''; savedData[`classsub_${i}`] = this.data[`classsub_${i}`] || ''; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/grid.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/grid.js index 1fc45ac8..9d3bd090 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/grid.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/grid.js @@ -68,15 +68,21 @@ class GridTool { // 1. Columns const colDiv = document.createElement('div'); - colDiv.className = 'col-md-2 mb-2'; + colDiv.className = 'col-md-3 mb-2'; colDiv.innerHTML = ''; + + const colControlGroup = document.createElement('div'); + colControlGroup.className = 'd-flex align-items-center'; + const colInput = document.createElement('input'); colInput.type = 'number'; - colInput.className = 'form-control form-control-sm'; + colInput.className = 'form-control form-control-sm mr-1'; + colInput.style.width = '65px'; colInput.min = '1'; colInput.max = '12'; colInput.value = this.data.cols; if (this.readOnly) colInput.disabled = true; + this.colsInput = colInput; colInput.addEventListener('input', (e) => { let val = parseInt(e.target.value) || 2; if (val < 1) val = 1; @@ -84,7 +90,18 @@ class GridTool { this.data.cols = val; this._renderColumnInputs(); }); - colDiv.appendChild(colInput); + colControlGroup.appendChild(colInput); + + const addColBtn = document.createElement('button'); + addColBtn.type = 'button'; + addColBtn.className = 'btn btn-sm btn-outline-primary font-weight-bold'; + addColBtn.title = 'Add new Column'; + addColBtn.innerHTML = ' + Col'; + if (this.readOnly) addColBtn.disabled = true; + addColBtn.addEventListener('click', () => this._addItem(this.data.cols)); + colControlGroup.appendChild(addColBtn); + + colDiv.appendChild(colControlGroup); settingsRow.appendChild(colDiv); // 2. Alignment Dropdown option (Left, Center, Right, Space Between, Space Around) @@ -156,7 +173,7 @@ class GridTool { // 5. Global HTML ID const idDiv = document.createElement('div'); - idDiv.className = 'col-md-3 mb-2'; + idDiv.className = 'col-md-2 mb-2'; idDiv.innerHTML = ''; const idInput = document.createElement('input'); idInput.type = 'text'; @@ -195,6 +212,277 @@ class GridTool { return this.wrapper; } + _getItemData(i) { + return { + type: this.data[`type${i}`] || 'html', + col: this.data[`col${i}`] || '', + imgUrl: this.data[`imgUrl${i}`] || '', + ytUrl: this.data[`ytUrl${i}`] || '', + ytThumbUrl: this.data[`ytThumbUrl${i}`] || '', + ytStretched: !!this.data[`ytStretched${i}`], + accTitle: this.data[`accTitle${i}`] || '', + accContent: this.data[`accContent${i}`] || '', + class: this.data[`class${i}`] || '', + id: this.data[`id${i}`] || '', + attr: this.data[`attr${i}`] || '', + width: this.data[`width${i}`] || 0, + caption: this.data[`caption${i}`] || '', + subType: this.data[`subType${i}`] || 'none', + colsub_: this.data[`colsub_${i}`] || '', + imgUrlsub_: this.data[`imgUrlsub_${i}`] || '', + ytUrlsub_: this.data[`ytUrlsub_${i}`] || '', + ytThumbUrlsub_: this.data[`ytThumbUrlsub_${i}`] || '', + ytStretchedsub_: !!this.data[`ytStretchedsub_${i}`], + accTitlesub_: this.data[`accTitlesub_${i}`] || '', + accContentsub_: this.data[`accContentsub_${i}`] || '', + classsub_: this.data[`classsub_${i}`] || '', + idsub_: this.data[`idsub_${i}`] || '', + attrsub_: this.data[`attrsub_${i}`] || '' + }; + } + + _setItemData(i, obj) { + this.data[`type${i}`] = obj.type || 'html'; + let colVal = obj.col !== undefined && obj.col !== null ? obj.col : ''; + if (typeof colVal === 'object') { + colVal = JSON.stringify(colVal); + } + this.data[`col${i}`] = colVal; + this.data[`imgUrl${i}`] = obj.imgUrl || ''; + this.data[`ytUrl${i}`] = obj.ytUrl || ''; + this.data[`ytThumbUrl${i}`] = obj.ytThumbUrl || obj.imgUrl || ''; + this.data[`ytStretched${i}`] = !!obj.ytStretched; + this.data[`accTitle${i}`] = obj.accTitle || ''; + this.data[`accContent${i}`] = obj.accContent || ''; + this.data[`class${i}`] = obj.class || ''; + this.data[`id${i}`] = obj.id || ''; + this.data[`attr${i}`] = obj.attr || ''; + this.data[`width${i}`] = obj.width || 0; + this.data[`caption${i}`] = obj.caption || ''; + this.data[`subType${i}`] = obj.subType || 'none'; + let colsubVal = obj.colsub_ !== undefined && obj.colsub_ !== null ? obj.colsub_ : ''; + if (typeof colsubVal === 'object') { + colsubVal = JSON.stringify(colsubVal); + } + this.data[`colsub_${i}`] = colsubVal; + this.data[`imgUrlsub_${i}`] = obj.imgUrlsub_ || ''; + this.data[`ytUrlsub_${i}`] = obj.ytUrlsub_ || ''; + this.data[`ytThumbUrlsub_${i}`] = obj.ytThumbUrlsub_ || obj.imgUrlsub_ || ''; + this.data[`ytStretchedsub_${i}`] = !!obj.ytStretchedsub_; + this.data[`accTitlesub_${i}`] = obj.accTitlesub_ || ''; + this.data[`accContentsub_${i}`] = obj.accContentsub_ || ''; + this.data[`classsub_${i}`] = obj.classsub_ || ''; + this.data[`idsub_${i}`] = obj.idsub_ || ''; + this.data[`attrsub_${i}`] = obj.attrsub_ || ''; + } + + _swapItems(i, j) { + const itemI = this._getItemData(i); + const itemJ = this._getItemData(j); + this._setItemData(i, itemJ); + this._setItemData(j, itemI); + this._renderColumnInputs(); + } + + _addItem(afterIdx) { + if (this.data.cols >= 12) return; + const newCols = this.data.cols + 1; + for (let i = newCols; i > afterIdx + 1; i--) { + this._setItemData(i, this._getItemData(i - 1)); + } + this._setItemData(afterIdx + 1, { type: 'html', col: '', subType: 'none' }); + this.data.cols = newCols; + if (this.colsInput) this.colsInput.value = newCols; + this._renderColumnInputs(); + } + + _deleteItem(idx) { + if (this.data.cols <= 1) return; + for (let i = idx; i < this.data.cols; i++) { + this._setItemData(i, this._getItemData(i + 1)); + } + this._setItemData(this.data.cols, { type: 'html', col: '', subType: 'none' }); + this.data.cols = this.data.cols - 1; + if (this.colsInput) this.colsInput.value = this.data.cols; + this._renderColumnInputs(); + } + + _showItem6DotsMenu(i, dragIcon) { + document.querySelectorAll('.sis-item-6dots-menu').forEach(m => m.remove()); + + const menu = document.createElement('div'); + menu.className = 'sis-item-6dots-menu bg-white border rounded shadow-lg p-2'; + menu.style.position = 'absolute'; + menu.style.zIndex = '999999'; + menu.style.minWidth = '220px'; + menu.style.fontSize = '12px'; + menu.style.boxShadow = '0 8px 24px rgba(0,0,0,0.18)'; + + const rect = dragIcon.getBoundingClientRect(); + menu.style.top = (rect.bottom + window.scrollY + 4) + 'px'; + menu.style.left = (rect.left + window.scrollX) + 'px'; + + const header = document.createElement('div'); + header.className = 'font-weight-bold text-primary mb-2 pb-1 border-bottom d-flex justify-content-between align-items-center'; + header.innerHTML = ` Tùy chọn Cột ${i}×`; + header.querySelector('.close-btn').addEventListener('click', () => menu.remove()); + menu.appendChild(header); + + // 1. Copy Item + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'btn btn-sm btn-light btn-block text-left mb-1'; + copyBtn.style.fontSize = '12px'; + copyBtn.innerHTML = ' Sao chép Cột này (Copy)'; + copyBtn.addEventListener('click', () => { + const itemData = this._getItemData(i); + const jsonStr = JSON.stringify(itemData); + localStorage.setItem('sis_copied_item_data', jsonStr); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(jsonStr).catch(() => {}); + } + copyBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1'; + copyBtn.innerHTML = ' Đã sao chép Cột!'; + setTimeout(() => menu.remove(), 800); + }); + menu.appendChild(copyBtn); + + // 2. Unified Paste (Handles both inner Item copy and Editor.js Block copy) + let dataToPaste = null; + const copiedRawItem = localStorage.getItem('sis_copied_item_data'); + const copiedRawBlock = localStorage.getItem('sis_copied_editor_block'); + + if (copiedRawItem) { + try { + dataToPaste = JSON.parse(copiedRawItem); + } catch (e) {} + } else if (copiedRawBlock) { + try { + const b = JSON.parse(copiedRawBlock); + if (b && b.type) { + dataToPaste = { + type: b.type, + col: JSON.stringify(b.data || {}), + subType: 'none', + class: '', + id: '', + attr: '', + width: 0 + }; + } + } catch (e) {} + } + + if (dataToPaste) { + // 2a. Paste into current column (overwrite) + const pasteBtn = document.createElement('button'); + pasteBtn.type = 'button'; + pasteBtn.className = 'btn btn-sm btn-light btn-block text-left mb-1'; + pasteBtn.style.fontSize = '12px'; + pasteBtn.innerHTML = ' Dán vào Cột này (ghi đè)'; + pasteBtn.addEventListener('click', () => { + this._setItemData(i, dataToPaste); + pasteBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1'; + pasteBtn.innerHTML = ' Đã dán Cột!'; + setTimeout(() => { + menu.remove(); + this._renderColumnInputs(); + }, 600); + }); + menu.appendChild(pasteBtn); + + // 2b. Paste as new column inserted after current + const pasteNewBtn = document.createElement('button'); + pasteNewBtn.type = 'button'; + pasteNewBtn.className = 'btn btn-sm btn-light btn-block text-left mb-1'; + pasteNewBtn.style.fontSize = '12px'; + pasteNewBtn.innerHTML = ' Dán thêm Cột mới phía sau'; + if (this.data.cols >= 12) pasteNewBtn.disabled = true; + pasteNewBtn.addEventListener('click', () => { + if (this.data.cols >= 12) return; + const newCols = this.data.cols + 1; + for (let k = newCols; k > i + 1; k--) { + this._setItemData(k, this._getItemData(k - 1)); + } + this._setItemData(i + 1, dataToPaste); + this.data.cols = newCols; + if (this.colsInput) this.colsInput.value = newCols; + pasteNewBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1'; + pasteNewBtn.innerHTML = ' Đã thêm Cột mới!'; + setTimeout(() => { + menu.remove(); + this._renderColumnInputs(); + }, 600); + }); + menu.appendChild(pasteNewBtn); + } + + // 3. Move Left / Move Right + const moveGroup = document.createElement('div'); + moveGroup.className = 'd-flex justify-content-between mb-1'; + + const moveLeft = document.createElement('button'); + moveLeft.type = 'button'; + moveLeft.className = 'btn btn-xs btn-outline-secondary flex-fill mr-1 py-1'; + moveLeft.style.fontSize = '11px'; + moveLeft.innerHTML = ' Sang trái'; + if (i <= 1) moveLeft.disabled = true; + moveLeft.addEventListener('click', () => { + this._swapItems(i, i - 1); + menu.remove(); + }); + moveGroup.appendChild(moveLeft); + + const moveRight = document.createElement('button'); + moveRight.type = 'button'; + moveRight.className = 'btn btn-xs btn-outline-secondary flex-fill ml-1 py-1'; + moveRight.style.fontSize = '11px'; + moveRight.innerHTML = 'Sang phải '; + if (i >= this.data.cols) moveRight.disabled = true; + moveRight.addEventListener('click', () => { + this._swapItems(i, i + 1); + menu.remove(); + }); + moveGroup.appendChild(moveRight); + menu.appendChild(moveGroup); + + // 4. Add Col After + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'btn btn-sm btn-light btn-block text-left text-success mb-1'; + addBtn.style.fontSize = '12px'; + addBtn.innerHTML = ' Thêm Cột mới phía sau'; + if (this.data.cols >= 12) addBtn.disabled = true; + addBtn.addEventListener('click', () => { + this._addItem(i); + menu.remove(); + }); + menu.appendChild(addBtn); + + // 5. Delete Col + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'btn btn-sm btn-light btn-block text-left text-danger mb-0'; + delBtn.style.fontSize = '12px'; + delBtn.innerHTML = ' Xóa Cột này'; + if (this.data.cols <= 1) delBtn.disabled = true; + delBtn.addEventListener('click', () => { + this._deleteItem(i); + menu.remove(); + }); + menu.appendChild(delBtn); + + document.body.appendChild(menu); + + const closeHandler = (evt) => { + if (!menu.contains(evt.target) && !dragIcon.contains(evt.target)) { + menu.remove(); + document.removeEventListener('click', closeHandler); + } + }; + setTimeout(() => document.addEventListener('click', closeHandler), 50); + } + _renderColumnInputs() { this.inputsContainer.innerHTML = ''; @@ -206,19 +494,112 @@ class GridTool { for (let i = 1; i <= count; i++) { const colDiv = document.createElement('div'); - colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white`; + colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white ce-grid-item-card`; colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)'; + colDiv.style.transition = 'border-color 0.2s, box-shadow 0.2s'; + colDiv.draggable = true; + + // HTML5 Drag & Drop handlers + colDiv.addEventListener('dragstart', (e) => { + e.dataTransfer.setData('text/plain', i); + colDiv.style.opacity = '0.5'; + }); + colDiv.addEventListener('dragend', () => { + colDiv.style.opacity = '1'; + }); + colDiv.addEventListener('dragover', (e) => { + e.preventDefault(); + colDiv.style.borderColor = '#007bff'; + colDiv.style.boxShadow = '0 0 0 2px rgba(0, 123, 255, 0.25)'; + }); + colDiv.addEventListener('dragleave', () => { + colDiv.style.borderColor = ''; + colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)'; + }); + colDiv.addEventListener('drop', (e) => { + e.preventDefault(); + colDiv.style.borderColor = ''; + colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)'; + const fromIdx = parseInt(e.dataTransfer.getData('text/plain')); + if (fromIdx && fromIdx !== i) { + this._swapItems(fromIdx, i); + } + }); const headerRow = document.createElement('div'); headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom'; + // Left Header: 6-dot drag icon, Col Label, Move Left/Right, Add Col (+), Delete (x) + const labelWrapper = document.createElement('div'); + labelWrapper.className = 'd-flex align-items-center'; + + // 6-dot Drag Handle Icon + const dragIcon = document.createElement('span'); + dragIcon.className = 'mr-2 text-muted sis-drag-handle-dots'; + dragIcon.style.cursor = 'grab'; + dragIcon.style.userSelect = 'none'; + dragIcon.style.display = 'inline-flex'; + dragIcon.style.alignItems = 'center'; + dragIcon.title = 'Kéo để sắp xếp, hoặc Click để mở menu chức năng đầy đủ'; + dragIcon.innerHTML = ``; + dragIcon.addEventListener('click', (e) => { + e.stopPropagation(); + this._showItem6DotsMenu(i, dragIcon); + }); + const label = document.createElement('label'); - label.className = 'small font-weight-bold text-dark mb-0'; + label.className = 'small font-weight-bold text-dark mb-0 mr-2'; label.innerText = `Col ${i}`; - headerRow.appendChild(label); + + // Controls: Move Left (◀), Move Right (▶), + Col, Delete (✕) + const moveLeftBtn = document.createElement('button'); + moveLeftBtn.type = 'button'; + moveLeftBtn.className = 'btn btn-xs btn-light border text-muted mr-1 px-1 py-0'; + moveLeftBtn.style.fontSize = '10px'; + moveLeftBtn.title = 'Move Left / Sang trái'; + moveLeftBtn.innerText = '◀'; + if (i === 1 || this.readOnly) moveLeftBtn.disabled = true; + moveLeftBtn.addEventListener('click', () => this._swapItems(i, i - 1)); + + const moveRightBtn = document.createElement('button'); + moveRightBtn.type = 'button'; + moveRightBtn.className = 'btn btn-xs btn-light border text-muted mr-1 px-1 py-0'; + moveRightBtn.style.fontSize = '10px'; + moveRightBtn.title = 'Move Right / Sang phải'; + moveRightBtn.innerText = '▶'; + if (i === count || this.readOnly) moveRightBtn.disabled = true; + moveRightBtn.addEventListener('click', () => this._swapItems(i, i + 1)); + + const addItemBtn = document.createElement('button'); + addItemBtn.type = 'button'; + addItemBtn.className = 'btn btn-xs btn-outline-success mr-1 px-1 py-0 font-weight-bold'; + addItemBtn.style.fontSize = '10px'; + addItemBtn.title = 'Insert new column after this / Thêm cột mới'; + addItemBtn.innerText = '+ Col'; + if (count >= 12 || this.readOnly) addItemBtn.disabled = true; + addItemBtn.addEventListener('click', () => this._addItem(i)); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'btn btn-xs btn-outline-danger px-1 py-0 font-weight-bold'; + deleteBtn.style.fontSize = '10px'; + deleteBtn.title = 'Delete column / Xóa cột'; + deleteBtn.innerText = '✕'; + if (count <= 1 || this.readOnly) deleteBtn.disabled = true; + deleteBtn.addEventListener('click', () => this._deleteItem(i)); + + labelWrapper.appendChild(dragIcon); + labelWrapper.appendChild(label); + labelWrapper.appendChild(moveLeftBtn); + labelWrapper.appendChild(moveRightBtn); + labelWrapper.appendChild(addItemBtn); + labelWrapper.appendChild(deleteBtn); + + headerRow.appendChild(labelWrapper); const controlsWrapper = document.createElement('div'); controlsWrapper.className = 'd-flex align-items-center gap-1'; + controlsWrapper.className = 'd-flex align-items-center gap-1'; // Width Selector const widthSelect = document.createElement('select'); @@ -272,8 +653,12 @@ class GridTool { // Dynamically add other registered plugins from window.SISEditorPlugins if (window.SISEditorPlugins) { + const seenPlugins = new Set(); Object.keys(window.SISEditorPlugins).forEach(key => { - // Allow nesting any registered plugins (no exclusions) + const entry = window.SISEditorPlugins[key]; + const pluginObj = entry ? (entry.class || entry) : null; + if (pluginObj && seenPlugins.has(pluginObj)) return; + if (pluginObj) seenPlugins.add(pluginObj); if (!types.some(t => t.value === key)) { types.push({ value: key, label: `[Plugin] ${key}` }); } @@ -330,7 +715,12 @@ class GridTool { { value: 'accordion', label: 'Accordion' } ]; if (window.SISEditorPlugins) { + const seenSubPlugins = new Set(); Object.keys(window.SISEditorPlugins).forEach(key => { + const entry = window.SISEditorPlugins[key]; + const pluginObj = entry ? (entry.class || entry) : null; + if (pluginObj && seenSubPlugins.has(pluginObj)) return; + if (pluginObj) seenSubPlugins.add(pluginObj); if (!subTypes.some(t => t.value === key)) { subTypes.push({ value: key, label: `[Plugin] ${key}` }); } @@ -393,6 +783,18 @@ class GridTool { renderSubInputs(); } + if (count < 12 && !this.readOnly) { + const addCardCol = document.createElement('div'); + addCardCol.className = 'col-12 mt-1 text-center'; + const addCardBtn = document.createElement('button'); + addCardBtn.type = 'button'; + addCardBtn.className = 'btn btn-sm btn-outline-primary font-weight-bold px-3 py-1 shadow-sm'; + addCardBtn.innerHTML = ' + Thêm Cột Mới'; + addCardBtn.addEventListener('click', () => this._addItem(count)); + addCardCol.appendChild(addCardBtn); + row.appendChild(addCardCol); + } + this.inputsContainer.appendChild(row); } @@ -506,8 +908,16 @@ class GridTool { if (window.SISMediaPicker) { SISMediaPicker.open((url, mediaObj, config) => { if (url) { + const fileObj = { + url: url, + style: (config && config.style) || '', + cssClass: (config && config.cssClass) || '', + alt: (config && config.alt) || '', + customAttributes: (config && config.customAttributes) || '', + aspectRatio: (config && config.aspectRatio) || '' + }; this.data[`col${i}`] = { - file: { url: url }, + file: fileObj, caption: (config && config.alt) || '', withBorder: false, withBackground: false, @@ -703,23 +1113,135 @@ class GridTool { }); } else if (type === 'youtube') { + const label = document.createElement('label'); + label.className = 'font-weight-bold text-danger small mb-1'; + label.innerHTML = ' Đường dẫn YouTube Video (URL / ID)'; + const input = document.createElement('input'); input.type = 'text'; input.className = 'form-control form-control-sm mb-2'; - input.placeholder = 'Paste YouTube video URL...'; + input.placeholder = 'Paste YouTube video URL (e.g. https://www.youtube.com/watch?v=sDE4ZZdNOOY)...'; input.value = this.data[`ytUrl${i}`] || ''; if (this.readOnly) input.disabled = true; - const preview = document.createElement('div'); - preview.className = 'small text-muted p-2 bg-light border rounded'; - preview.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX'; + // Custom Thumbnail Group + const thumbLabel = document.createElement('label'); + thumbLabel.className = 'font-weight-bold text-secondary small mb-1'; + thumbLabel.innerHTML = ' Ảnh bìa Custom Thumbnail (Tùy chọn - Đè lên bìa YouTube)'; + + const thumbGroup = document.createElement('div'); + thumbGroup.className = 'input-group input-group-sm mb-2'; + + const thumbInput = document.createElement('input'); + thumbInput.type = 'text'; + thumbInput.className = 'form-control'; + thumbInput.placeholder = 'URL ảnh bìa hoặc chọn từ Thư viện Media...'; + thumbInput.value = this.data[`ytThumbUrl${i}`] || this.data[`imgUrl${i}`] || ''; + if (this.readOnly) thumbInput.disabled = true; + + const thumbAppend = document.createElement('div'); + thumbAppend.className = 'input-group-append'; + + const mediaBtn = document.createElement('button'); + mediaBtn.type = 'button'; + mediaBtn.className = 'btn btn-outline-primary font-weight-bold'; + mediaBtn.innerHTML = ' Thư viện Media'; + if (this.readOnly) mediaBtn.disabled = true; + + mediaBtn.addEventListener('click', (e) => { + e.preventDefault(); + if (window.SISMediaPicker) { + SISMediaPicker.open((selectedUrl) => { + thumbInput.value = selectedUrl; + this.data[`ytThumbUrl${i}`] = selectedUrl; + this.data[`imgUrl${i}`] = selectedUrl; + updatePreview(); + }); + } else { + alert('Thư viện Media (SISMediaPicker) chưa được tải.'); + } + }); + + thumbAppend.appendChild(mediaBtn); + thumbGroup.appendChild(thumbInput); + thumbGroup.appendChild(thumbAppend); + + // Stretch / Full-width Switch + const stretchWrapper = document.createElement('div'); + stretchWrapper.className = 'custom-control custom-switch mb-2'; + + const stretchCheck = document.createElement('input'); + stretchCheck.type = 'checkbox'; + stretchCheck.className = 'custom-control-input'; + const switchId = 'yt_item_stretch_' + i + '_' + Math.random().toString(36).substring(7); + stretchCheck.id = switchId; + stretchCheck.checked = !!(this.data[`ytStretched${i}`]); + if (this.readOnly) stretchCheck.disabled = true; + + const stretchLabel = document.createElement('label'); + stretchLabel.className = 'custom-control-label small font-weight-bold text-dark'; + stretchLabel.htmlFor = switchId; + stretchLabel.style.cursor = 'pointer'; + stretchLabel.innerHTML = ' Ép full chiều rộng (Force Full Width)'; + + stretchCheck.addEventListener('change', () => { + this.data[`ytStretched${i}`] = stretchCheck.checked; + }); + + stretchWrapper.appendChild(stretchCheck); + stretchWrapper.appendChild(stretchLabel); + + // Thumbnail Preview Box + const previewBox = document.createElement('div'); + previewBox.className = 'yt-preview-card p-2 bg-light border rounded text-center mb-2'; + previewBox.style.minHeight = '60px'; + previewBox.style.borderRadius = '6px'; + previewBox.style.background = '#111 center/cover no-repeat'; + previewBox.style.position = 'relative'; + + const updatePreview = () => { + const url = input.value.trim(); + const customThumb = thumbInput.value.trim(); + let videoId = ''; + var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/; + var match = url.match(regExp); + if (match && match[2].length === 11) videoId = match[2]; + else if (url.length === 11) videoId = url; + + const thumbUrl = customThumb || (videoId ? 'https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg' : ''); + if (thumbUrl) { + previewBox.style.backgroundImage = 'url("' + thumbUrl + '")'; + previewBox.style.paddingBottom = '56.25%'; + previewBox.style.height = '0'; + previewBox.style.display = 'block'; + } else { + previewBox.style.backgroundImage = 'none'; + previewBox.style.paddingBottom = '0'; + previewBox.style.height = 'auto'; + previewBox.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX'; + } + }; input.addEventListener('input', (e) => { this.data[`ytUrl${i}`] = e.target.value.trim(); + updatePreview(); }); + thumbInput.addEventListener('input', (e) => { + const val = e.target.value.trim(); + this.data[`ytThumbUrl${i}`] = val; + this.data[`imgUrl${i}`] = val; + updatePreview(); + }); + + updatePreview(); + + typeContainer.appendChild(label); typeContainer.appendChild(input); - typeContainer.appendChild(preview); + typeContainer.appendChild(thumbLabel); + typeContainer.appendChild(thumbGroup); + typeContainer.appendChild(stretchWrapper); + typeContainer.appendChild(previewBox); } else if (type === 'accordion') { const titleInput = document.createElement('input'); @@ -820,7 +1342,14 @@ class GridTool { } savedData[`imgUrl${i}`] = this.data[`imgUrl${i}`] || ''; + savedData[`imgId${i}`] = this.data[`imgId${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? (this.data[`col${i}`].file.id || this.data[`col${i}`].file.elementId) : ''); + savedData[`imgStyle${i}`] = this.data[`imgStyle${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.style : ''); + savedData[`imgClass${i}`] = this.data[`imgClass${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.cssClass : ''); + savedData[`imgAlt${i}`] = this.data[`imgAlt${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.alt : ''); + savedData[`imgAttrs${i}`] = this.data[`imgAttrs${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.customAttributes : ''); savedData[`ytUrl${i}`] = this.data[`ytUrl${i}`] || ''; + savedData[`ytThumbUrl${i}`] = this.data[`ytThumbUrl${i}`] || ''; + savedData[`ytStretched${i}`] = !!this.data[`ytStretched${i}`]; savedData[`accTitle${i}`] = this.data[`accTitle${i}`] || ''; savedData[`accContent${i}`] = this.data[`accContent${i}`] || ''; savedData[`class${i}`] = this.data[`class${i}`] || ''; @@ -847,6 +1376,8 @@ class GridTool { savedData[`imgUrlsub_${i}`] = this.data[`imgUrlsub_${i}`] || ''; savedData[`ytUrlsub_${i}`] = this.data[`ytUrlsub_${i}`] || ''; + savedData[`ytThumbUrlsub_${i}`] = this.data[`ytThumbUrlsub_${i}`] || ''; + savedData[`ytStretchedsub_${i}`] = !!this.data[`ytStretchedsub_${i}`]; savedData[`accTitlesub_${i}`] = this.data[`accTitlesub_${i}`] || ''; savedData[`accContentsub_${i}`] = this.data[`accContentsub_${i}`] || ''; savedData[`classsub_${i}`] = this.data[`classsub_${i}`] || ''; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/hover-card.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/hover-card.js index 53427d87..67f769c2 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/hover-card.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/hover-card.js @@ -89,6 +89,12 @@ class SISHoverCardTool { if (this.data.styleVariant === 'v2') optV2.selected = true; variantSelect.appendChild(optV2); + const optV3 = document.createElement('option'); + optV3.value = 'v3'; + optV3.textContent = 'Hover 3 (Horizontal Layout)'; + if (this.data.styleVariant === 'v3') optV3.selected = true; + variantSelect.appendChild(optV3); + if (this.readOnly) variantSelect.disabled = true; variantSelect.addEventListener('change', (e) => { this.data.styleVariant = e.target.value; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/posts.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/posts.js index 2874d4da..233abbf1 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/posts.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/posts.js @@ -75,6 +75,7 @@ class SISPostsTool { const styleOpts = [ { val: 'grid', lbl: 'Grid Cards (Default)' }, { val: 'bento_layout_01', lbl: 'Bento Layout 01' }, + { val: 'bento_layout_02', lbl: 'Bento Layout 02' }, { val: 'card_grid_layout', lbl: 'Card Grid Layout' }, { val: 'list', lbl: 'Horizontal List' }, { val: 'cards', lbl: 'Featured Large Cards' }, diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-end.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-end.js index 37f0b8b2..a4c40414 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-end.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-end.js @@ -29,3 +29,9 @@ class TabEndTool { return {}; } } + +// Register globally +window.TabEndTool = TabEndTool; +window.SISEditorPlugins = window.SISEditorPlugins || {}; +window.SISEditorPlugins['tabEnd'] = { class: TabEndTool }; +window.SISEditorPlugins['tab-end'] = window.SISEditorPlugins['tabEnd']; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-plugin.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-plugin.js new file mode 100644 index 00000000..13de77b3 --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-plugin.js @@ -0,0 +1,439 @@ +/** + * SISTabPluginTool — Dedicated Multi-Tab Block Tool for Editor.js (SIS Vietnam) + * Allows content editors to add responsive, tabbed content panels inside pages. + * Fully supports Custom CSS Class, HTML ID, Inline Style, and Custom Attributes for both Wrapper & individual Tabs. + */ +class SISTabPluginTool { + static get toolbox() { + return { + title: 'Tab Panel', + icon: '' + }; + } + + constructor({ data, api, readOnly }) { + this.api = api; + this.readOnly = readOnly; + this.instanceId = 'tp_' + Math.random().toString(36).substring(7); + this.activeInstances = {}; + + this.data = { + globalId: (data && data.globalId) || '', + globalClass: (data && data.globalClass) || '', + globalStyle: (data && data.globalStyle) || '', + globalAttributes: (data && data.globalAttributes) || (data && data.attributes) || '', + items: (data && data.items && Array.isArray(data.items)) ? data.items : [] + }; + + if (this.data.items.length === 0) { + this.data.items.push( + { title: 'Tab 1', type: 'html', content: 'Nội dung Tab 1...', class: '', id: '', style: '', attributes: '' }, + { title: 'Tab 2', type: 'html', content: 'Nội dung Tab 2...', class: '', id: '', style: '', attributes: '' } + ); + } + this.wrapper = undefined; + } + + render() { + this.wrapper = document.createElement('div'); + this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-tab-plugin-tool-wrapper'; + this.wrapper.style.fontFamily = 'inherit'; + + const headerDiv = document.createElement('div'); + headerDiv.className = 'd-flex justify-content-between align-items-center mb-3 pb-2 border-bottom'; + headerDiv.innerHTML = '
Multi-Tab Panel Settings
'; + + if (!this.readOnly) { + const addTabTopBtn = document.createElement('button'); + addTabTopBtn.type = 'button'; + addTabTopBtn.className = 'btn btn-xs btn-success shadow-sm'; + addTabTopBtn.style.fontSize = '12px'; + addTabTopBtn.innerHTML = ' Thêm Tab Mới'; + addTabTopBtn.addEventListener('click', () => this._addTab()); + headerDiv.appendChild(addTabTopBtn); + } + this.wrapper.appendChild(headerDiv); + + // Global Class, ID, Style & Attributes row + const globalRow = document.createElement('div'); + globalRow.className = 'form-row mb-3 pb-2 border-bottom'; + + // 1. CSS Class + const classDiv = document.createElement('div'); + classDiv.className = 'col-md-3 mb-2'; + classDiv.innerHTML = ''; + const classInput = document.createElement('input'); + classInput.type = 'text'; + classInput.className = 'form-control form-control-sm'; + classInput.placeholder = 'e.g. my-tabs-wrapper'; + classInput.value = this.data.globalClass || ''; + if (this.readOnly) classInput.disabled = true; + classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim()); + classDiv.appendChild(classInput); + globalRow.appendChild(classDiv); + + // 2. HTML ID + const idDiv = document.createElement('div'); + idDiv.className = 'col-md-3 mb-2'; + idDiv.innerHTML = ''; + const idInput = document.createElement('input'); + idInput.type = 'text'; + idInput.className = 'form-control form-control-sm'; + idInput.placeholder = 'e.g. section-tabs-1'; + idInput.value = this.data.globalId || ''; + if (this.readOnly) idInput.disabled = true; + idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim()); + idDiv.appendChild(idInput); + globalRow.appendChild(idDiv); + + // 3. Inline Style + const styleDiv = document.createElement('div'); + styleDiv.className = 'col-md-3 mb-2'; + styleDiv.innerHTML = ''; + const styleInput = document.createElement('input'); + styleInput.type = 'text'; + styleInput.className = 'form-control form-control-sm'; + styleInput.placeholder = 'e.g. background:#fff; padding:20px;'; + styleInput.value = this.data.globalStyle || ''; + if (this.readOnly) styleInput.disabled = true; + styleInput.addEventListener('input', (e) => this.data.globalStyle = e.target.value.trim()); + styleDiv.appendChild(styleInput); + globalRow.appendChild(styleDiv); + + // 4. Custom Attributes + const attrDiv = document.createElement('div'); + attrDiv.className = 'col-md-3 mb-2'; + attrDiv.innerHTML = ''; + const attrInput = document.createElement('input'); + attrInput.type = 'text'; + attrInput.className = 'form-control form-control-sm'; + attrInput.placeholder = 'e.g. data-aos="fade-up"'; + attrInput.value = this.data.globalAttributes || ''; + if (this.readOnly) attrInput.disabled = true; + attrInput.addEventListener('input', (e) => this.data.globalAttributes = e.target.value.trim()); + attrDiv.appendChild(attrInput); + globalRow.appendChild(attrDiv); + + this.wrapper.appendChild(globalRow); + + // Container for Tab items + this.tabsContainer = document.createElement('div'); + this.wrapper.appendChild(this.tabsContainer); + + this._renderTabs(); + + // Bottom Add Tab Button + if (!this.readOnly) { + const bottomActionDiv = document.createElement('div'); + bottomActionDiv.className = 'text-center mt-3 pt-2 border-top'; + const addTabBottomBtn = document.createElement('button'); + addTabBottomBtn.type = 'button'; + addTabBottomBtn.className = 'btn btn-sm btn-outline-primary shadow-sm px-4'; + addTabBottomBtn.innerHTML = ' + Thêm Tab Mới'; + addTabBottomBtn.addEventListener('click', () => this._addTab()); + bottomActionDiv.appendChild(addTabBottomBtn); + this.wrapper.appendChild(bottomActionDiv); + } + + return this.wrapper; + } + + _addTab() { + const nextNum = this.data.items.length + 1; + this.data.items.push({ + title: `Tab ${nextNum}`, + type: 'html', + content: '', + class: '', + id: '', + style: '', + attributes: '' + }); + this._renderTabs(); + } + + _deleteTab(index) { + if (this.data.items.length <= 1) return; + this.data.items.splice(index, 1); + this._renderTabs(); + } + + _moveTab(index, direction) { + const newIndex = index + direction; + if (newIndex < 0 || newIndex >= this.data.items.length) return; + const temp = this.data.items[index]; + this.data.items[index] = this.data.items[newIndex]; + this.data.items[newIndex] = temp; + this._renderTabs(); + } + + _renderTabs() { + this.tabsContainer.innerHTML = ''; + this.activeInstances = {}; + + this.data.items.forEach((item, index) => { + const card = document.createElement('div'); + card.className = 'mb-3 p-3 border rounded bg-white ce-tab-item-card shadow-sm position-relative'; + card.draggable = !this.readOnly; + + // Drag and drop handlers + card.addEventListener('dragstart', (e) => { + e.dataTransfer.setData('text/plain', index); + card.style.opacity = '0.5'; + }); + card.addEventListener('dragend', () => card.style.opacity = '1'); + card.addEventListener('dragover', (e) => e.preventDefault()); + card.addEventListener('drop', (e) => { + e.preventDefault(); + const fromIdx = parseInt(e.dataTransfer.getData('text/plain')); + if (!isNaN(fromIdx) && fromIdx !== index) { + const movedItem = this.data.items.splice(fromIdx, 1)[0]; + this.data.items.splice(index, 0, movedItem); + this._renderTabs(); + } + }); + + // Card Header Row + const headerRow = document.createElement('div'); + headerRow.className = 'd-flex justify-content-between align-items-center mb-3 pb-2 border-bottom'; + + const leftControls = document.createElement('div'); + leftControls.className = 'd-flex align-items-center gap-2'; + + const dragHandle = document.createElement('span'); + dragHandle.className = 'mr-2 text-muted'; + dragHandle.style.cursor = 'grab'; + dragHandle.innerHTML = '⋮⋮'; + leftControls.appendChild(dragHandle); + + const badge = document.createElement('span'); + badge.className = 'badge badge-primary mr-2'; + badge.innerText = `Tab #${index + 1}`; + leftControls.appendChild(badge); + + // Title input + const titleInput = document.createElement('input'); + titleInput.type = 'text'; + titleInput.className = 'form-control form-control-sm font-weight-bold'; + titleInput.style.width = '180px'; + titleInput.placeholder = 'Tên Tab (Ví dụ: Tổng quan)...'; + titleInput.value = item.title || ''; + if (this.readOnly) titleInput.disabled = true; + titleInput.addEventListener('input', (e) => item.title = e.target.value); + leftControls.appendChild(titleInput); + + headerRow.appendChild(leftControls); + + const rightControls = document.createElement('div'); + rightControls.className = 'd-flex align-items-center gap-1'; + + // Move left / right buttons + const moveLeftBtn = document.createElement('button'); + moveLeftBtn.type = 'button'; + moveLeftBtn.className = 'btn btn-xs btn-outline-secondary mr-1'; + moveLeftBtn.innerText = '◀'; + if (index === 0 || this.readOnly) moveLeftBtn.disabled = true; + moveLeftBtn.addEventListener('click', () => this._moveTab(index, -1)); + + const moveRightBtn = document.createElement('button'); + moveRightBtn.type = 'button'; + moveRightBtn.className = 'btn btn-xs btn-outline-secondary mr-2'; + moveRightBtn.innerText = '▶'; + if (index === this.data.items.length - 1 || this.readOnly) moveRightBtn.disabled = true; + moveRightBtn.addEventListener('click', () => this._moveTab(index, 1)); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'btn btn-xs btn-danger'; + deleteBtn.innerText = '✕'; + if (this.data.items.length <= 1 || this.readOnly) deleteBtn.disabled = true; + deleteBtn.addEventListener('click', () => this._deleteTab(index)); + + rightControls.appendChild(moveLeftBtn); + rightControls.appendChild(moveRightBtn); + rightControls.appendChild(deleteBtn); + + headerRow.appendChild(rightControls); + card.appendChild(headerRow); + + // Type Selector + const typeRow = document.createElement('div'); + typeRow.className = 'form-group mb-2'; + const typeLabel = document.createElement('label'); + typeLabel.className = 'small font-weight-bold text-secondary mb-1'; + typeLabel.innerText = 'Loại nội dung của Tab:'; + typeRow.appendChild(typeLabel); + + const typeSelect = document.createElement('select'); + typeSelect.className = 'custom-select custom-select-sm'; + if (this.readOnly) typeSelect.disabled = true; + + const types = [ + { value: 'html', label: 'HTML / Text' }, + { value: 'tinymce', label: 'TinyMCE Editor' }, + { value: 'image', label: 'Hình ảnh (Image)' }, + { value: 'youtube', label: 'Video YouTube' }, + { value: 'accordion', label: 'Accordion (Xổ xuống)' } + ]; + + if (window.SISEditorPlugins) { + const seenPlugins = new Set(); + Object.keys(window.SISEditorPlugins).forEach(key => { + const entry = window.SISEditorPlugins[key]; + const pluginObj = entry ? (entry.class || entry) : null; + if (pluginObj && seenPlugins.has(pluginObj)) return; + if (pluginObj) seenPlugins.add(pluginObj); + if (!types.some(t => t.value === key)) { + types.push({ value: key, label: `[Plugin] ${key}` }); + } + }); + } + + types.forEach(t => { + const opt = document.createElement('option'); + opt.value = t.value; + opt.innerText = t.label; + if ((item.type || 'html') === t.value) opt.selected = true; + typeSelect.appendChild(opt); + }); + + typeRow.appendChild(typeSelect); + card.appendChild(typeRow); + + // Advanced Item Settings (Class, ID, Style, Attributes) + const itemAdvRow = document.createElement('div'); + itemAdvRow.className = 'form-row mb-2 p-2 bg-light rounded border'; + + const itemClassDiv = document.createElement('div'); + itemClassDiv.className = 'col-md-3 mb-1'; + const itemClassInput = document.createElement('input'); + itemClassInput.type = 'text'; + itemClassInput.className = 'form-control form-control-sm'; + itemClassInput.placeholder = 'Item Class...'; + itemClassInput.value = item.class || ''; + if (this.readOnly) itemClassInput.disabled = true; + itemClassInput.addEventListener('input', (e) => item.class = e.target.value.trim()); + itemClassDiv.appendChild(itemClassInput); + itemAdvRow.appendChild(itemClassDiv); + + const itemIdDiv = document.createElement('div'); + itemIdDiv.className = 'col-md-3 mb-1'; + const itemIdInput = document.createElement('input'); + itemIdInput.type = 'text'; + itemIdInput.className = 'form-control form-control-sm'; + itemIdInput.placeholder = 'Item ID...'; + itemIdInput.value = item.id || ''; + if (this.readOnly) itemIdInput.disabled = true; + itemIdInput.addEventListener('input', (e) => item.id = e.target.value.trim()); + itemIdDiv.appendChild(itemIdInput); + itemAdvRow.appendChild(itemIdDiv); + + const itemStyleDiv = document.createElement('div'); + itemStyleDiv.className = 'col-md-3 mb-1'; + const itemStyleInput = document.createElement('input'); + itemStyleInput.type = 'text'; + itemStyleInput.className = 'form-control form-control-sm'; + itemStyleInput.placeholder = 'Item Style...'; + itemStyleInput.value = item.style || ''; + if (this.readOnly) itemStyleInput.disabled = true; + itemStyleInput.addEventListener('input', (e) => item.style = e.target.value.trim()); + itemStyleDiv.appendChild(itemStyleInput); + itemAdvRow.appendChild(itemStyleDiv); + + const itemAttrDiv = document.createElement('div'); + itemAttrDiv.className = 'col-md-3 mb-1'; + const itemAttrInput = document.createElement('input'); + itemAttrInput.type = 'text'; + itemAttrInput.className = 'form-control form-control-sm'; + itemAttrInput.placeholder = 'Item Custom Attrs...'; + itemAttrInput.value = item.attributes || item.customAttributes || ''; + if (this.readOnly) itemAttrInput.disabled = true; + itemAttrInput.addEventListener('input', (e) => item.attributes = e.target.value.trim()); + itemAttrDiv.appendChild(itemAttrInput); + itemAdvRow.appendChild(itemAttrDiv); + + card.appendChild(itemAdvRow); + + const contentContainer = document.createElement('div'); + contentContainer.className = 'tab-content-input-container mt-2'; + + typeSelect.addEventListener('change', (e) => { + item.type = e.target.value; + this._renderTabContentInput(index, item, contentContainer); + }); + + card.appendChild(contentContainer); + this._renderTabContentInput(index, item, contentContainer); + + this.tabsContainer.appendChild(card); + }); + } + + _renderTabContentInput(index, item, container) { + container.innerHTML = ''; + const type = item.type || 'html'; + + if (window.SISEditorPlugins && window.SISEditorPlugins[type]) { + try { + const PluginClass = window.SISEditorPlugins[type].class; + const pluginInstance = new PluginClass({ + data: (typeof item.content === 'object' && item.content !== null) ? item.content : {}, + api: this.api, + readOnly: this.readOnly + }); + this.activeInstances[index] = pluginInstance; + const pluginNode = pluginInstance.render(); + container.appendChild(pluginNode); + return; + } catch (err) { + console.warn(`[SISTabPluginTool] Error rendering plugin '${type}' for tab ${index}:`, err); + } + } + + // Standard HTML Textarea + const textarea = document.createElement('textarea'); + textarea.className = 'form-control form-control-sm'; + textarea.rows = 4; + textarea.placeholder = 'Nhập nội dung HTML hoặc văn bản cho Tab này...'; + textarea.value = (typeof item.content === 'string') ? item.content : JSON.stringify(item.content || ''); + if (this.readOnly) textarea.disabled = true; + textarea.addEventListener('input', (e) => item.content = e.target.value); + container.appendChild(textarea); + } + + save() { + // Collect current plugin values if available + this.data.items.forEach((item, index) => { + if (this.activeInstances[index] && typeof this.activeInstances[index].save === 'function') { + try { + item.content = this.activeInstances[index].save(); + } catch (err) { + console.warn(`[SISTabPluginTool] Save error for tab ${index}:`, err); + } + } + }); + + return { + globalId: this.data.globalId || '', + globalClass: this.data.globalClass || '', + globalStyle: this.data.globalStyle || '', + globalAttributes: this.data.globalAttributes || '', + items: this.data.items + }; + } + + destroy() { + Object.keys(this.activeInstances).forEach(key => { + if (this.activeInstances[key] && typeof this.activeInstances[key].destroy === 'function') { + try { this.activeInstances[key].destroy(); } catch (e) {} + } + }); + this.activeInstances = {}; + } +} + +// Register globally +window.SISTabPluginTool = SISTabPluginTool; +window.SISEditorPlugins = window.SISEditorPlugins || {}; +window.SISEditorPlugins['tabPlugin'] = { class: SISTabPluginTool }; +window.SISEditorPlugins['tab-plugin'] = window.SISEditorPlugins['tabPlugin']; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-split.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-split.js index 2affd0f8..9a0ebb07 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-split.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-split.js @@ -63,3 +63,9 @@ class TabSplitTool { return {}; // We only care about the block type, no inner data needed } } + +// Register globally +window.TabSplitTool = TabSplitTool; +window.SISEditorPlugins = window.SISEditorPlugins || {}; +window.SISEditorPlugins['tabSplit'] = { class: TabSplitTool }; +window.SISEditorPlugins['tab-split'] = window.SISEditorPlugins['tabSplit']; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-start.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-start.js index 419c4b24..87761346 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-start.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tab-start.js @@ -42,9 +42,15 @@ class TabStartTool { } save(blockContent) { - const input = blockContent.querySelector('input'); + const input = blockContent ? blockContent.querySelector('input') : null; return { - title: input ? input.value : this.data.title + title: input ? input.value : (this.data.title || '') }; } } + +// Register globally +window.TabStartTool = TabStartTool; +window.SISEditorPlugins = window.SISEditorPlugins || {}; +window.SISEditorPlugins['tabStart'] = { class: TabStartTool }; +window.SISEditorPlugins['tab-start'] = window.SISEditorPlugins['tabStart']; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/text-styling.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/text-styling.js index 7c6e4575..350e29d5 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/text-styling.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/text-styling.js @@ -24,6 +24,108 @@ class TextStylingTune { wrapper.className = 'cdx-settings-text-styling p-2 border-top'; wrapper.style.fontFamily = 'inherit'; + // === Copy & Paste Block Section === + const copySection = document.createElement('div'); + copySection.className = 'sis-block-copy-paste-tune mb-2 pb-2 border-bottom'; + + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'btn btn-sm btn-outline-primary btn-block text-left mb-1'; + copyBtn.style.fontSize = '12px'; + copyBtn.style.fontWeight = '600'; + copyBtn.style.padding = '4px 8px'; + copyBtn.innerHTML = ' Sao chép khối (Copy Block)'; + copyBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + let blockData = null; + if (this.block && typeof this.block.save === 'function') { + blockData = await this.block.save(); + } else if (this.api && this.api.saver) { + const blockIdx = this.api.blocks.getBlockIndex(this.block.id); + const savedOutput = await this.api.saver.save(); + if (savedOutput && savedOutput.blocks && savedOutput.blocks[blockIdx]) { + blockData = savedOutput.blocks[blockIdx]; + } + } + + const blockType = (blockData && (blockData.tool || blockData.type)) || (this.block ? (this.block.name || this.block.tool) : 'paragraph'); + const blockContent = blockData ? (blockData.data || {}) : {}; + + const blockPayload = { + type: blockType, + data: blockContent, + tunes: (blockData && blockData.tunes) || this.data || {} + }; + const itemPayload = { + type: blockType, + col: typeof blockContent === 'string' ? blockContent : JSON.stringify(blockContent), + subType: 'none', + class: '', + id: '', + attr: '', + width: 0 + }; + + const jsonStr = JSON.stringify(blockPayload); + localStorage.setItem('sis_copied_editor_block', jsonStr); + localStorage.setItem('sis_copied_item_data', JSON.stringify(itemPayload)); + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(jsonStr).catch(() => {}); + } + copyBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1'; + copyBtn.innerHTML = ' Đã sao chép khối!'; + setTimeout(() => { + copyBtn.className = 'btn btn-sm btn-outline-primary btn-block text-left mb-1'; + copyBtn.innerHTML = ' Sao chép khối (Copy Block)'; + }, 2000); + } catch (err) { + console.error('[Copy Block Error]', err); + } + }); + copySection.appendChild(copyBtn); + + const copiedRaw = localStorage.getItem('sis_copied_editor_block'); + if (copiedRaw) { + try { + const parsedBlock = JSON.parse(copiedRaw); + if (parsedBlock && parsedBlock.type) { + const pasteBtn = document.createElement('button'); + pasteBtn.type = 'button'; + pasteBtn.className = 'btn btn-sm btn-outline-success btn-block text-left mb-0'; + pasteBtn.style.fontSize = '12px'; + pasteBtn.style.fontWeight = '600'; + pasteBtn.style.padding = '4px 8px'; + pasteBtn.innerHTML = ' Dán khối đã chép bên dưới'; + pasteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (this.api && this.api.blocks) { + const curIdx = this.api.blocks.getBlockIndex(this.block.id); + this.api.blocks.insert( + parsedBlock.type, + parsedBlock.data || {}, + {}, + curIdx + 1, + true, + false, + parsedBlock.tunes || {} + ); + pasteBtn.className = 'btn btn-sm btn-success btn-block text-left mb-0'; + pasteBtn.innerHTML = ' Đã dán khối thành công!'; + setTimeout(() => { + pasteBtn.className = 'btn btn-sm btn-outline-success btn-block text-left mb-0'; + pasteBtn.innerHTML = ' Dán khối đã chép bên dưới'; + }, 2000); + } + }); + copySection.appendChild(pasteBtn); + } + } catch (e) {} + } + + wrapper.appendChild(copySection); + const title = document.createElement('div'); title.className = 'small font-weight-bold text-primary mb-2'; title.innerHTML = ' Style & Alignment'; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tiny-mce.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tiny-mce.js index f0309639..cd0948ee 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tiny-mce.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tiny-mce.js @@ -21,13 +21,16 @@ render() { const wrapper = document.createElement('div'); - wrapper.className = 'sis-tinymce-block-wrapper border rounded p-2 bg-white'; + wrapper.className = 'sis-tinymce-block-wrapper border rounded p-2 bg-white d-flex flex-column h-100 flex-fill'; wrapper.style.minHeight = '280px'; + wrapper.style.height = '100%'; + wrapper.style.flex = '1 1 auto'; const textarea = document.createElement('textarea'); textarea.id = this.editorId; textarea.value = this.data.html; textarea.style.width = '100%'; + textarea.style.height = '100%'; textarea.style.minHeight = '250px'; wrapper.appendChild(textarea); this.textareaElement = textarea; @@ -88,13 +91,52 @@ tinymce.init({ target: textarea, - height: 250, + height: '100%', + min_height: 250, menubar: false, readonly: !!self.readOnly, plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount', toolbar: 'undo redo | blocks | bold italic forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image | code removeformat', + image_dimensions: true, + object_resizing: 'img', + content_style: 'body { font-family: Roboto, ui-sans-serif, sans-serif; font-size: 14px; color: #333; } img { max-width: 100%; height: auto; cursor: pointer; display: inline-block; }', + paste_postprocess: function(plugin, args) { + if (args && args.node) { + args.node.querySelectorAll('img').forEach(function(img) { + img.style.removeProperty('inline-size'); + img.style.removeProperty('block-size'); + if (img.hasAttribute('width') && img.getAttribute('width') !== '100%') { + img.style.removeProperty('width'); + img.style.removeProperty('height'); + } + }); + } + }, setup: function(editor) { self.editorInstance = editor; + editor.on('init', function() { + const container = editor.getContainer(); + if (container) { + container.style.flex = '1 1 auto'; + container.style.height = '100%'; + container.style.minHeight = '250px'; + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + + const editArea = container.querySelector('.tox-edit-area'); + if (editArea) { + editArea.style.flex = '1 1 auto'; + editArea.style.display = 'flex'; + editArea.style.flexDirection = 'column'; + } + + const iframe = container.querySelector('iframe'); + if (iframe) { + iframe.style.height = '100%'; + iframe.style.flex = '1 1 auto'; + } + } + }); editor.on('change keyup undo redo', function() { self.data.html = editor.getContent(); }); diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/youtube-plugin.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/youtube-plugin.js new file mode 100644 index 00000000..bea612d8 --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/youtube-plugin.js @@ -0,0 +1,241 @@ +(function() { + 'use strict'; + + class SISYouTubeTool { + static get toolbox() { + return { + title: 'YouTube Video', + icon: '' + }; + } + + constructor({ data, api, readOnly }) { + this.api = api; + this.readOnly = readOnly; + this.data = { + url: (data && data.url) ? data.url : '', + thumbnailUrl: (data && data.thumbnailUrl) ? data.thumbnailUrl : '', + caption: (data && data.caption) ? data.caption : '', + fullWidth: !!(data && (data.fullWidth || data.stretched)) + }; + this.urlInput = null; + this.thumbnailInput = null; + this.captionInput = null; + this.previewContainer = null; + this.iframe = null; + this.isIframeActive = false; + } + + extractVideoId(input) { + if (!input) return ''; + var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/; + var match = input.match(regExp); + if (match && match[2].length === 11) { + return match[2]; + } + if (input.length === 11) { + return input; + } + return ''; + } + + getEmbedUrl(videoId) { + return videoId ? 'https://www.youtube.com/embed/' + videoId : ''; + } + + getThumbnailUrl(videoId) { + return videoId ? 'https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg' : ''; + } + + render() { + const container = document.createElement('div'); + container.style.border = '1px solid #e3e6f0'; + container.style.borderRadius = '8px'; + container.style.padding = '14px'; + container.style.background = '#fff'; + container.style.marginBottom = '12px'; + + const label = document.createElement('label'); + label.className = 'font-weight-bold text-danger small mb-1'; + label.innerHTML = ' YouTube Video Link / URL / ID'; + + this.urlInput = document.createElement('input'); + this.urlInput.type = 'text'; + this.urlInput.className = 'form-control mb-2'; + this.urlInput.placeholder = 'Paste YouTube URL (e.g. https://www.youtube.com/watch?v=sDE4ZZdNOOY)...'; + this.urlInput.value = this.data.url; + + // Custom Thumbnail Field with Media Library button + const thumbLabel = document.createElement('label'); + thumbLabel.className = 'font-weight-bold text-secondary small mb-1'; + thumbLabel.innerHTML = ' Ảnh bìa Video (Custom Thumbnail Cover Image)'; + + const thumbGroup = document.createElement('div'); + thumbGroup.className = 'input-group input-group-sm mb-2'; + + this.thumbnailInput = document.createElement('input'); + this.thumbnailInput.type = 'text'; + this.thumbnailInput.className = 'form-control'; + this.thumbnailInput.placeholder = 'URL ảnh bìa hoặc chọn từ thư viện media...'; + this.thumbnailInput.value = this.data.thumbnailUrl; + + const thumbAppend = document.createElement('div'); + thumbAppend.className = 'input-group-append'; + + const mediaBtn = document.createElement('button'); + mediaBtn.type = 'button'; + mediaBtn.className = 'btn btn-outline-primary font-weight-bold'; + mediaBtn.innerHTML = ' Thư viện Media'; + mediaBtn.addEventListener('click', (e) => { + e.preventDefault(); + if (window.SISMediaPicker) { + SISMediaPicker.open((selectedUrl) => { + this.thumbnailInput.value = selectedUrl; + this.data.thumbnailUrl = selectedUrl; + updatePreview(); + }); + } else { + alert('Thư viện Media (SISMediaPicker) chưa được tải.'); + } + }); + + thumbAppend.appendChild(mediaBtn); + thumbGroup.appendChild(this.thumbnailInput); + thumbGroup.appendChild(thumbAppend); + + // Thumbnail Preview Container + this.previewContainer = document.createElement('div'); + this.previewContainer.className = 'yt-thumbnail-preview'; + this.previewContainer.style.position = 'relative'; + this.previewContainer.style.paddingBottom = '56.25%'; + this.previewContainer.style.height = '0'; + this.previewContainer.style.overflow = 'hidden'; + this.previewContainer.style.background = '#111 center/cover no-repeat'; + this.previewContainer.style.borderRadius = '6px'; + this.previewContainer.style.marginBottom = '8px'; + this.previewContainer.style.cursor = 'pointer'; + this.previewContainer.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)'; + + // Play Button Overlay + const playBtn = document.createElement('div'); + playBtn.className = 'yt-play-button-overlay'; + playBtn.style.position = 'absolute'; + playBtn.style.top = '50%'; + playBtn.style.left = '50%'; + playBtn.style.transform = 'translate(-50%, -50%)'; + playBtn.style.transition = 'transform 0.2s ease'; + playBtn.innerHTML = ''; + + this.previewContainer.appendChild(playBtn); + + // Update Thumbnail Image + const updatePreview = () => { + const customThumb = this.thumbnailInput.value.trim(); + const videoId = this.extractVideoId(this.urlInput.value); + const thumbUrl = customThumb || this.getThumbnailUrl(videoId); + if (thumbUrl) { + this.previewContainer.style.backgroundImage = 'url("' + thumbUrl + '")'; + playBtn.style.display = 'block'; + } else { + this.previewContainer.style.backgroundImage = 'none'; + this.previewContainer.style.backgroundColor = '#222'; + } + }; + + // Click thumbnail to play live video + this.previewContainer.addEventListener('click', () => { + const videoId = this.extractVideoId(this.urlInput.value); + if (videoId && !this.isIframeActive) { + this.isIframeActive = true; + this.previewContainer.innerHTML = ''; + const iframe = document.createElement('iframe'); + iframe.style.position = 'absolute'; + iframe.style.top = '0'; + iframe.style.left = '0'; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = '0'; + iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'); + iframe.setAttribute('allowfullscreen', 'true'); + iframe.src = this.getEmbedUrl(videoId) + '?autoplay=1'; + this.previewContainer.appendChild(iframe); + } + }); + + updatePreview(); + + this.captionInput = document.createElement('input'); + this.captionInput.type = 'text'; + this.captionInput.className = 'form-control form-control-sm mb-2'; + this.captionInput.placeholder = 'Video caption (optional)...'; + this.captionInput.value = this.data.caption; + + if (this.readOnly) { + this.urlInput.disabled = true; + this.thumbnailInput.disabled = true; + mediaBtn.disabled = true; + this.captionInput.disabled = true; + } + + this.urlInput.addEventListener('input', () => { + const videoId = this.extractVideoId(this.urlInput.value); + this.data.url = this.getEmbedUrl(videoId); + this.isIframeActive = false; + this.previewContainer.innerHTML = ''; + this.previewContainer.appendChild(playBtn); + updatePreview(); + }); + + this.thumbnailInput.addEventListener('input', () => { + this.data.thumbnailUrl = this.thumbnailInput.value.trim(); + updatePreview(); + }); + + this.captionInput.addEventListener('input', () => { + this.data.caption = this.captionInput.value; + }); + + container.appendChild(label); + container.appendChild(this.urlInput); + container.appendChild(thumbLabel); + container.appendChild(thumbGroup); + container.appendChild(this.previewContainer); + container.appendChild(this.captionInput); + + const stretchWrapper = document.createElement('div'); + stretchWrapper.className = 'custom-control custom-switch mt-2'; + this.fullWidthCheck = document.createElement('input'); + this.fullWidthCheck.type = 'checkbox'; + this.fullWidthCheck.className = 'custom-control-input'; + this.fullWidthCheck.id = 'yt_stretch_' + Math.random().toString(36).substring(7); + this.fullWidthCheck.checked = !!(this.data && this.data.fullWidth); + + const stretchLabel = document.createElement('label'); + stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary'; + stretchLabel.htmlFor = this.fullWidthCheck.id; + stretchLabel.innerHTML = ' Stretch block to Full Screen Width (Independent Breakout)'; + + this.fullWidthCheck.addEventListener('change', () => { + this.data.fullWidth = this.fullWidthCheck.checked; + }); + + stretchWrapper.appendChild(this.fullWidthCheck); + stretchWrapper.appendChild(stretchLabel); + container.appendChild(stretchWrapper); + + return container; + } + + save() { + const videoId = this.extractVideoId(this.urlInput ? this.urlInput.value : this.data.url); + return { + url: this.getEmbedUrl(videoId) || this.data.url, + thumbnailUrl: this.thumbnailInput ? this.thumbnailInput.value.trim() : (this.data.thumbnailUrl || ''), + caption: this.captionInput ? this.captionInput.value : this.data.caption, + fullWidth: this.fullWidthCheck ? this.fullWidthCheck.checked : !!(this.data && this.data.fullWidth) + }; + } + } + + window.SISYouTubeTool = SISYouTubeTool; +})(); diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/sis-media-picker.js b/sisvietnamvn_main/src/main/resources/static/js/manage/sis-media-picker.js index a045ab96..0170d1c7 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/sis-media-picker.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/sis-media-picker.js @@ -78,120 +78,155 @@ /* ── Body ─── */ '' + '' + /* Footer */ @@ -243,7 +278,7 @@ } }); - // Upload + // Upload & Quick Size dropdown modalEl.addEventListener('change', function (e) { if (e.target && e.target.id === 'sisMediaUploadInput') { var file = e.target.files && e.target.files[0]; @@ -251,13 +286,36 @@ self._doUpload(file); // reset so same file can be uploaded again e.target.value = ''; + } else if (e.target && e.target.id === 'sisMediaConfigQuickSize') { + var val = e.target.value; + if (!val) return; + var parts = val.split(','); + var w = parts[0] || ''; + var h = parts[1] || ''; + var wEl = document.getElementById('sisMediaConfigWidth'); + var hEl = document.getElementById('sisMediaConfigHeight'); + if (wEl) wEl.value = w; + if (hEl) hEl.value = h; } }); // Button clicks modalEl.addEventListener('click', function (e) { var id = e.target && e.target.id; + var changeBtn = e.target && e.target.closest && (e.target.closest('#sisMediaChangeImageBtn') || e.target.closest('#sisMediaChangeImageBtn2')); var refreshBtn = e.target && e.target.closest && e.target.closest('#sisMediaRefreshBtn'); + + if (changeBtn || id === 'sisMediaChangeImageBtn' || id === 'sisMediaChangeImageBtn2') { + var gridWrapper = document.getElementById('sisMediaGridWrapper'); + if (gridWrapper) { + gridWrapper.style.display = 'block'; + gridWrapper.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + if (!self.currentPage && !self.loading) { + self._resetAndFetch(); + } + return; + } if (refreshBtn || id === 'sisMediaRefreshBtn') { self._resetAndFetch(); return; } @@ -280,7 +338,28 @@ }, /* ── open ────────────────────────────────────────────── */ - open: function (callback) { + open: function (arg1, arg2, arg3) { + var callback = null; + var initialUrl = null; + var initialConfig = null; + + if (typeof arg1 === 'function') { + callback = arg1; + if (typeof arg2 === 'string') { + initialUrl = arg2; + initialConfig = arg3 || {}; + } else if (typeof arg2 === 'object' && arg2 !== null) { + initialUrl = arg2.url || arg2.currentUrl || arg2.initialUrl || arg2.fileUrl || ''; + initialConfig = arg2.config || arg2.currentConfig || arg2.initialConfig || arg2; + } + } else if (typeof arg1 === 'object' && arg1 !== null) { + initialUrl = arg1.url || arg1.currentUrl || arg1.initialUrl || arg1.fileUrl || ''; + initialConfig = arg1.config || arg1.currentConfig || arg1.initialConfig || arg1; + if (typeof arg2 === 'function') { + callback = arg2; + } + } + this.callback = callback; var modalEl = this.initModal(); @@ -296,7 +375,18 @@ if (searchEl) searchEl.value = ''; this.currentKeyword = ''; - this._resetAndFetch(); + var gridWrapper = document.getElementById('sisMediaGridWrapper'); + + if (initialUrl) { + // Image previously selected: show Config Panel immediately, hide library grid until user clicks "Đổi ảnh khác" + if (gridWrapper) gridWrapper.style.display = 'none'; + var filename = initialUrl.split('/').pop(); + this.showConfigPanel(initialUrl, { url: initialUrl, name: filename }, initialConfig); + } else { + // New selection: show library grid immediately + if (gridWrapper) gridWrapper.style.display = 'block'; + this._resetAndFetch(); + } }, /* ── internal helpers ───────────────────────────────── */ @@ -497,7 +587,7 @@ }, /* ── Config Panel ────────────────────────────────────── */ - showConfigPanel: function (url, mediaObj) { + showConfigPanel: function (url, mediaObj, initialConfig) { this.selectedUrl = url; this.selectedMediaObj = mediaObj || {}; @@ -507,22 +597,52 @@ var previewImg = document.getElementById('sisMediaConfigPreview'); if (previewImg) previewImg.src = url; + var filename = (mediaObj && (mediaObj.originalFilename || mediaObj.name)) || (url ? url.split('/').pop() : ''); var nameEl = document.getElementById('sisMediaConfigName'); - if (nameEl) nameEl.textContent = (mediaObj && (mediaObj.originalFilename || mediaObj.name)) || ''; + if (nameEl) nameEl.textContent = filename; // Reset all fields - ['sisMediaConfigWidth','sisMediaConfigHeight','sisMediaConfigStyle','sisMediaConfigClass','sisMediaConfigAlt','sisMediaConfigAttrs'].forEach(function (id) { + ['sisMediaConfigWidth','sisMediaConfigHeight','sisMediaConfigStyle','sisMediaConfigClass','sisMediaConfigId','sisMediaConfigAlt','sisMediaConfigAttrs'].forEach(function (id) { var el = document.getElementById(id); if (el) el.value = ''; }); - ['sisMediaConfigObjectFit','sisMediaConfigAspectRatio'].forEach(function (id) { + ['sisMediaConfigQuickSize','sisMediaConfigObjectFit','sisMediaConfigAspectRatio'].forEach(function (id) { var el = document.getElementById(id); if (el) el.value = ''; }); - var sizeContainer = document.getElementById('sisMediaSizeButtons'); - if (sizeContainer) { - sizeContainer.querySelectorAll('button').forEach(function (b) { - b.classList.replace('btn-secondary', 'btn-outline-secondary'); - }); + // Pre-fill initialConfig if provided + if (initialConfig) { + var setVal = function(id, val) { + if (val !== undefined && val !== null && val !== '') { + var el = document.getElementById(id); + if (el) el.value = val; + } + }; + setVal('sisMediaConfigWidth', initialConfig.width); + setVal('sisMediaConfigHeight', initialConfig.height); + setVal('sisMediaConfigObjectFit', initialConfig.objectFit); + setVal('sisMediaConfigAspectRatio', initialConfig.aspectRatio); + setVal('sisMediaConfigId', initialConfig.id || initialConfig.elementId); + setVal('sisMediaConfigClass', initialConfig.cssClass || initialConfig.class); + setVal('sisMediaConfigStyle', initialConfig.style); + setVal('sisMediaConfigAlt', initialConfig.alt); + setVal('sisMediaConfigAttrs', initialConfig.customAttributes || initialConfig.customAttrs); + + // Parse style string if width/height/fit/aspect fields are empty + if (initialConfig.style && typeof initialConfig.style === 'string') { + var styleStr = initialConfig.style; + var matchProp = function(prop) { + var m = styleStr.match(new RegExp('(?:^|;\\s*)' + prop + '\\s*:\\s*([^;]+)', 'i')); + return m ? m[1].trim() : ''; + }; + var w = matchProp('width'); + var h = matchProp('height'); + var fit = matchProp('object-fit'); + var ratio = matchProp('aspect-ratio'); + if (w && !document.getElementById('sisMediaConfigWidth').value) document.getElementById('sisMediaConfigWidth').value = w; + if (h && !document.getElementById('sisMediaConfigHeight').value) document.getElementById('sisMediaConfigHeight').value = h; + if (fit && !document.getElementById('sisMediaConfigObjectFit').value) document.getElementById('sisMediaConfigObjectFit').value = fit; + if (ratio && !document.getElementById('sisMediaConfigAspectRatio').value) document.getElementById('sisMediaConfigAspectRatio').value = ratio; + } } panel.style.display = 'block'; @@ -546,6 +666,7 @@ var height = g('sisMediaConfigHeight'); var objectFit = g('sisMediaConfigObjectFit'); var aspectRatio= g('sisMediaConfigAspectRatio'); + var elementId = g('sisMediaConfigId'); var style = g('sisMediaConfigStyle'); var cssClass = g('sisMediaConfigClass'); var alt = g('sisMediaConfigAlt'); @@ -560,9 +681,17 @@ computedStyle = computedStyle.trim(); var config = { - style: computedStyle, cssClass: cssClass, alt: alt, - customAttributes: customAttrs, width: width, height: height, - objectFit: objectFit, aspectRatio: aspectRatio + id: elementId, + elementId: elementId, + style: computedStyle, + cssClass: cssClass, + class: cssClass, + alt: alt, + customAttributes: customAttrs, + width: width, + height: height, + objectFit: objectFit, + aspectRatio: aspectRatio }; if (typeof this.callback === 'function') { diff --git a/sisvietnamvn_main/src/main/resources/templates/manage/pages/form.html b/sisvietnamvn_main/src/main/resources/templates/manage/pages/form.html index de965110..06d5c442 100644 --- a/sisvietnamvn_main/src/main/resources/templates/manage/pages/form.html +++ b/sisvietnamvn_main/src/main/resources/templates/manage/pages/form.html @@ -462,6 +462,10 @@ + + + + diff --git a/sisvietnamvn_main/src/main/resources/templates/manage/posts/form.html b/sisvietnamvn_main/src/main/resources/templates/manage/posts/form.html index 245c39d7..a5483f7d 100644 --- a/sisvietnamvn_main/src/main/resources/templates/manage/posts/form.html +++ b/sisvietnamvn_main/src/main/resources/templates/manage/posts/form.html @@ -412,7 +412,7 @@ 'bold italic backcolor | alignleft aligncenter ' + 'alignright alignjustify | bullist numlist outdent indent | ' + 'table tablemergecells tablesplitcells | ' + - 'insertYouTube | insertTabStart insertTabEnd editClassId | previewTabs | removeformat | help', + 'insertYouTube | insertTabStart insertTabEnd insertMultiTabPanel editClassId | previewTabs | removeformat | help', toolbar_mode: 'wrap', content_style: ` body { font-family:Helvetica,Arial,sans-serif; font-size:16px; } @@ -454,6 +454,15 @@ font-weight: 600; margin-top: 4px; } + .sis-multi-tab-wrapper { + border: 1px dashed #ccc; + border-radius: 6px; + padding: 0; + margin: 24px 0; + } + .sis-multi-tab-wrapper [data-sis-tab-panel] { + font-family: inherit; + } `, // Convert URLs to absolute if needed, or keep relative convert_urls: false, @@ -491,8 +500,8 @@ }); editor.ui.registry.addButton('insertTabStart', { - text: 'Thêm Tab', - tooltip: 'Tạo một Tab ngang mới (Dành cho Chi tiết khoá học)', + text: '📑 Thêm Tab', + tooltip: 'Tạo một tab mới (đặt tên tab, rồi nhập nội dung vào trong)', onAction: function (_) { var tabName = prompt('Nhập tiêu đề Tab (ví dụ: Tổng quan):', 'Tiêu đề Tab'); if (tabName) { @@ -502,13 +511,115 @@ }); editor.ui.registry.addButton('insertTabEnd', { - text: 'Kết thúc Tab', + text: '⏹ Kết thúc Tab', tooltip: 'Đóng/Kết thúc nhóm Tab hiện tại', onAction: function (_) { editor.insertContent('

 


 

'); }, }); + // ─── Multi-Tab Panel Builder ───────────────────────────────────────────── + editor.ui.registry.addButton('insertMultiTabPanel', { + text: '🗂 Tạo Multi-Tab', + tooltip: 'Tạo khung nhiều Tab (có thể chứa bảng, hình ảnh, văn bản...)', + onAction: function (_) { + // Unique ID for this panel + var uid = 'tab-panel-' + Date.now(); + + editor.windowManager.open({ + title: 'Tạo Khung Multi-Tab', + size: 'medium', + body: { + type: 'panel', + items: [ + { + type: 'htmlpanel', + html: [ + '
', + '

', + '📌 Nhập tên các Tab (mỗi dòng một Tab). Sau khi tạo, click vào từng tab để nhập nội dung.', + '

', + '', + '
', + '', + '', + '#881c1c', + '
', + '
', + ].join(''), + }, + ], + }, + buttons: [ + { type: 'cancel', text: 'Hủy' }, + { + type: 'submit', + text: '✅ Tạo Tab ngay', + primary: true, + onAction: function () {}, + }, + ], + onSubmit: function (api) { + var textarea = document.getElementById('sisTabNamesInput'); + var colorInput = document.getElementById('sisTabColorInput'); + var rawNames = textarea ? textarea.value : ''; + var color = colorInput ? colorInput.value : '#881c1c'; + + var tabNames = rawNames + .split('\n') + .map(function (s) { return s.trim(); }) + .filter(function (s) { return s.length > 0; }); + + if (tabNames.length === 0) { + alert('Bạn chưa nhập tên Tab nào!'); + return; + } + + // Build tab HTML + var tabBtnsHtml = ''; + var tabPanelsHtml = ''; + tabNames.forEach(function (name, i) { + var isActive = (i === 0); + var btnStyle = isActive + ? 'background:' + color + ';color:#fff;border:2px solid ' + color + ';' + : 'background:#fff;color:' + color + ';border:2px solid ' + color + ';border-left:none;'; + tabBtnsHtml += ''; + + tabPanelsHtml += '
' + + '

Nội dung Tab ' + name + ' — hãy thay thế dòng này (có thể chèn bảng, hình ảnh, danh sách...).

' + + '
'; + }); + + var fullHtml = '
' + + '
' + tabBtnsHtml + '
' + + tabPanelsHtml + + '
' + + '

 

'; + + editor.insertContent(fullHtml); + api.close(); + }, + }); + + // Attach color preview listener after dialog opens + setTimeout(function () { + var colorEl = document.getElementById('sisTabColorInput'); + var hexEl = document.getElementById('sisTabColorHex'); + if (colorEl && hexEl) { + colorEl.addEventListener('input', function () { + hexEl.textContent = colorEl.value; + }); + } + }, 50); + }, + }); + // ───────────────────────────────────────────────────────────────────────── + editor.ui.registry.addButton('previewTabs', { text: '👁 Xem trước', tooltip: 'Xem trước bài viết với Tab hoạt động giống giao diện thực', diff --git a/sisvietnamvn_main/src/main/resources/templates/page.html b/sisvietnamvn_main/src/main/resources/templates/page.html index 09f47f60..d72cecbe 100644 --- a/sisvietnamvn_main/src/main/resources/templates/page.html +++ b/sisvietnamvn_main/src/main/resources/templates/page.html @@ -36,10 +36,15 @@
- - + +
-
+ +
+
+ +
+
@@ -73,8 +78,23 @@ - -

+
+ + + +

+
@@ -130,7 +150,7 @@ -
+
@@ -403,6 +423,7 @@ th:id="${block.data['id' + i] != null and !#strings.isEmpty(block.data['id' + i]) ? block.data['id' + i] : null}" th:class="${(block.data['width' + i] != null and block.data['width' + i] gt 0) ? ('col-lg-' + block.data['width' + i] + ' col-md-6 col-12 mb-4') : (block.data['cols'] == 1 ? 'col-12 mb-4' : (block.data['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (block.data['cols'] == 3 ? 'col-lg-4 col-md-6 col-12 mb-4' : (block.data['cols'] == 4 ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg col-md-6 col-12 mb-4'))))}" th:classappend="${block.data['class' + i] != null and !#strings.isEmpty(block.data['class' + i]) ? block.data['class' + i] : ''}" + th:styleappend="${(block.data['direction'] == 'column' or block.data['direction'] == 'column-reverse') ? 'width: 100%; flex: 1 1 auto;' : ((block.data['width' + i] == null or block.data['width' + i] == 0) ? 'flex: 1 1 0%; min-width: 250px;' : '')}" th:data-custom-attrs="${block.data['attr' + i] != null and !#strings.isEmpty(block.data['attr' + i]) ? block.data['attr' + i] : null}" >
@@ -416,25 +437,27 @@
-
+
-
-
+
-
-
+
+
-

Title

-

Description

+
+

Title

+

Description

+
@@ -443,6 +466,60 @@
+ + +
+
+
+ +
+ +
+ + +
+ + + +
+ + +
+ +
+
+
+
+
+
+
+
+
+
+ + + +
+ +
+
+