feat: implement responsive tab plugin and new hover card v3 component, and add admin controllers for price and recruiting tables.

This commit is contained in:
2026-08-04 21:44:13 +07:00
parent 89c18f20fd
commit 373ee63716
42 changed files with 6148 additions and 1008 deletions
@@ -419,11 +419,16 @@ public class PageController {
if (editorData.containsKey("blocks")) {
blocks = (List<Map<String, Object>>) editorData.get("blocks");
for (Map<String, Object> block : blocks) {
if ("snippet".equals(block.get("type"))) {
if ("snippet".equals(block.get("type")) || "cmsPlugin".equals(block.get("type"))) {
Map<String, Object> data = (Map<String, Object>) 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")));
@@ -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<PriceTable> 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<List<PriceTable>>() {});
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<PriceTable> parseTablesJson(String json) {
if (json == null || json.trim().isEmpty() || "[]".equals(json)) {
return new ArrayList<>();
}
try {
return objectMapper.readValue(json, new TypeReference<List<PriceTable>>() {});
} 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<PriceItem> items = new ArrayList<>();
public PriceCategory() {}
public PriceCategory(String categoryName, List<PriceItem> items) {
this.categoryName = categoryName;
this.items = items;
}
public String getCategoryName() { return categoryName; }
public void setCategoryName(String categoryName) { this.categoryName = categoryName; }
public List<PriceItem> getItems() { return items; }
public void setItems(List<PriceItem> 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<PriceCategory> categories = new ArrayList<>();
public PriceTable() {}
public PriceTable(String id, String name, String tab1Name, String tab2Name, String pdfUrl, List<PriceCategory> 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<PriceCategory> getCategories() { return categories; }
public void setCategories(List<PriceCategory> categories) { this.categories = categories; }
}
}
@@ -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 += "<a class=\"collapse-item\" href=\"/manage/plugins/price-table\">Bảng giá Dịch vụ</a>\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<priceTableAdminController.PriceTable> 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 "<p class='text-muted text-center py-4'>Chưa có dữ liệu Bảng giá.</p>";
}
// 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<priceTableAdminController.PriceCategory> categories = selectedTable.getCategories() != null ? selectedTable.getCategories() : new ArrayList<>();
StringBuilder html = new StringBuilder();
html.append("<style>");
html.append(".pt-container-").append(tableId).append(" { max-width: 1140px; margin: 0 auto; padding: 20px 15px; font-family: system-ui, -apple-system, sans-serif; }");
html.append(".pt-tabs-").append(tableId).append(" { display: flex; justify-content: center; gap: 40px; border-bottom: 2px solid #e2e8f0; margin-bottom: 28px; }");
html.append(".pt-tab-").append(tableId).append(" { font-size: 18px; font-weight: 700; color: #475569; padding: 12px 16px; cursor: pointer; border-bottom: 3px solid transparent; margin-bottom: -2px; transition: all 0.2s; }");
html.append(".pt-tab-").append(tableId).append(".active { color: var(--color-old-brick, #9b1c2b); border-bottom-color: var(--color-old-brick, #9b1c2b); }");
html.append(".pt-search-bar-").append(tableId).append(" { display: flex; gap: 12px; max-width: 720px; margin: 0 auto 30px auto; }");
html.append(".pt-search-input-").append(tableId).append(" { flex: 1; padding: 12px 20px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 15px; background: #fafafa; outline: none; transition: border 0.2s; }");
html.append(".pt-search-input-").append(tableId).append(":focus { border-color: var(--color-old-brick, #9b1c2b); background: #fff; box-shadow: 0 0 0 3px rgba(155, 28, 43, 0.15); }");
html.append(".pt-search-btn-").append(tableId).append(" { display: flex; align-items: center; gap: 8px; padding: 12px 24px; background: var(--color-old-brick, #9b1c2b); color: #fff; font-weight: 600; font-size: 15px; border: none; border-radius: 8px; cursor: pointer; transition: background 0.2s; white-space: nowrap; }");
html.append(".pt-search-btn-").append(tableId).append(":hover { background: #7a1522; }");
html.append(".pt-table-card-").append(tableId).append(" { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); }");
html.append(".pt-table-header-").append(tableId).append(" { display: grid; grid-template-columns: 2fr 1fr 1fr; background: var(--color-old-brick, #9b1c2b); color: #fff; font-weight: 700; padding: 14px 20px; font-size: 15px; }");
html.append(".pt-table-header-").append(tableId).append(" div:nth-child(2), .pt-table-header-").append(tableId).append(" div:nth-child(3) { text-align: center; }");
html.append(".pt-cat-row-").append(tableId).append(" { display: flex; align-items: center; justify-content: space-between; background: #fdf2f4; color: var(--color-old-brick, #9b1c2b); font-weight: 700; padding: 14px 20px; font-size: 15px; cursor: pointer; border-bottom: 1px solid #e2e8f0; transition: background 0.2s; user-select: none; }");
html.append(".pt-cat-row-").append(tableId).append(":hover { background: #fbe5e9; }");
html.append(".pt-cat-arrow-").append(tableId).append(" { transition: transform 0.25s ease; font-size: 14px; }");
html.append(".pt-cat-row-").append(tableId).append(".expanded .pt-cat-arrow-").append(tableId).append(" { transform: rotate(180deg); }");
html.append(".pt-item-list-").append(tableId).append(" { display: none; }");
html.append(".pt-item-list-").append(tableId).append(".open { display: block; }");
html.append(".pt-item-row-").append(tableId).append(" { display: grid; grid-template-columns: 2fr 1fr 1fr; padding: 12px 20px 12px 36px; border-bottom: 1px solid #f1f5f9; font-size: 14px; color: #334155; transition: background 0.15s; }");
html.append(".pt-item-row-").append(tableId).append(":hover { background: #f8fafc; }");
html.append(".pt-item-row-").append(tableId).append(" div:nth-child(2), .pt-item-row-").append(tableId).append(" div:nth-child(3) { text-align: center; font-weight: 500; color: #0f172a; }");
html.append(".pt-pdf-wrapper-").append(tableId).append(" { background: #f1f5f9; border-radius: 12px; padding: 20px; text-align: center; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05); }");
html.append(".pt-pdf-iframe-").append(tableId).append(" { width: 100%; height: 850px; border: none; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }");
html.append("@media (max-width: 640px) { .pt-table-header-").append(tableId).append(", .pt-item-row-").append(tableId).append(" { grid-template-columns: 1.5fr 1fr 1fr; font-size: 13px; padding-left: 12px; padding-right: 12px; } .pt-cat-row-").append(tableId).append(" { font-size: 13px; padding: 12px; } .pt-pdf-iframe-").append(tableId).append(" { height: 500px; } }");
html.append("</style>");
html.append("<div class='pt-container-").append(tableId).append("'>");
html.append(" <div class='pt-tabs-").append(tableId).append("'>");
html.append(" <div class='pt-tab-").append(tableId).append(" active' onclick='switchPtTab_").append(tableId).append("(this, 1)'>").append(escapeHtml(tab1Name)).append("</div>");
html.append(" <div class='pt-tab-").append(tableId).append("' onclick='switchPtTab_").append(tableId).append("(this, 2)'>").append(escapeHtml(tab2Name)).append("</div>");
html.append(" </div>");
// TAB 1 CONTENT
html.append(" <div id='ptTab1_").append(tableId).append("'>");
html.append(" <div class='pt-search-bar-").append(tableId).append("'>");
html.append(" <input type='text' id='ptSearchInput_").append(tableId).append("' class='pt-search-input-").append(tableId).append("' placeholder='Nhập thông tin cần tìm...' onkeyup='filterPtItems_").append(tableId).append("()'/>");
html.append(" <button class='pt-search-btn-").append(tableId).append("' onclick='filterPtItems_").append(tableId).append("()'>");
html.append(" <svg width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.3-4.3'/></svg>");
html.append(" Tra cứu");
html.append(" </button>");
html.append(" </div>");
html.append(" <div class='pt-table-card-").append(tableId).append("'>");
html.append(" <div class='pt-table-header-").append(tableId).append("'>");
html.append(" <div>Tên dịch vụ kỹ thuật</div>");
html.append(" <div>Mức thu</div>");
html.append(" <div>Mức BHYT</div>");
html.append(" </div>");
html.append(" <div id='ptCategoriesList_").append(tableId).append("'>");
for (int cIdx = 0; cIdx < categories.size(); cIdx++) {
priceTableAdminController.PriceCategory cat = categories.get(cIdx);
String catName = cat.getCategoryName() != null ? cat.getCategoryName() : "";
List<priceTableAdminController.PriceItem> items = cat.getItems() != null ? cat.getItems() : new ArrayList<>();
html.append(" <div class='pt-cat-group-").append(tableId).append("'>");
html.append(" <div class='pt-cat-row-").append(tableId).append("' onclick='togglePtCat_").append(tableId).append("(this)'>");
html.append(" <div class='pt-cat-title'>").append(escapeHtml(catName)).append("</div>");
html.append(" <div class='pt-cat-arrow-").append(tableId).append("'>▼</div>");
html.append(" </div>");
html.append(" <div class='pt-item-list-").append(tableId).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(" <div class='pt-item-row-").append(tableId).append("' data-name='").append(escapeHtml(name.toLowerCase())).append("'>");
html.append(" <div>").append(escapeHtml(name)).append("</div>");
html.append(" <div>").append(escapeHtml(uPrice)).append("</div>");
html.append(" <div>").append(escapeHtml(bPrice)).append("</div>");
html.append(" </div>");
}
html.append(" </div>");
html.append(" </div>");
}
html.append(" </div>");
html.append(" </div>");
html.append(" </div>"); // END TAB 1
// TAB 2 CONTENT (PDF Viewer)
html.append(" <div id='ptTab2_").append(tableId).append("' style='display: none;'>");
html.append(" <div class='pt-pdf-wrapper-").append(tableId).append("'>");
if (!pdfUrl.isEmpty()) {
html.append(" <iframe class='pt-pdf-iframe-").append(tableId).append("' src='").append(escapeHtml(pdfUrl)).append("#toolbar=1'></iframe>");
} else {
html.append(" <div class='py-5 text-muted'>");
html.append(" <i class='far fa-file-pdf fa-3x mb-3'></i>");
html.append(" <p class='m-0'>Chưa cấu hình file PDF cho danh mục kỹ thuật này.</p>");
html.append(" </div>");
}
html.append(" </div>");
html.append(" </div>"); // END TAB 2
html.append("</div>");
html.append("<script>");
html.append("function togglePtCat_").append(tableId).append("(rowEl) {");
html.append(" rowEl.classList.toggle('expanded');");
html.append(" var listEl = rowEl.nextElementSibling;");
html.append(" if(listEl) listEl.classList.toggle('open');");
html.append("}");
html.append("function switchPtTab_").append(tableId).append("(el, tabIndex) {");
html.append(" document.querySelectorAll('.pt-tab-").append(tableId).append("').forEach(function(t){ t.classList.remove('active'); });");
html.append(" el.classList.add('active');");
html.append(" var t1 = document.getElementById('ptTab1_").append(tableId).append("');");
html.append(" var t2 = document.getElementById('ptTab2_").append(tableId).append("');");
html.append(" if (tabIndex === 1) {");
html.append(" if(t1) t1.style.display = 'block';");
html.append(" if(t2) t2.style.display = 'none';");
html.append(" } else {");
html.append(" if(t1) t1.style.display = 'none';");
html.append(" if(t2) t2.style.display = 'block';");
html.append(" }");
html.append("}");
html.append("function filterPtItems_").append(tableId).append("() {");
html.append(" var query = (document.getElementById('ptSearchInput_").append(tableId).append("').value || '').trim().toLowerCase();");
html.append(" var groups = document.querySelectorAll('.pt-cat-group-").append(tableId).append("');");
html.append(" groups.forEach(function(group) {");
html.append(" var items = group.querySelectorAll('.pt-item-row-").append(tableId).append("');");
html.append(" var catRow = group.querySelector('.pt-cat-row-").append(tableId).append("');");
html.append(" var itemList = group.querySelector('.pt-item-list-").append(tableId).append("');");
html.append(" var hasMatch = false;");
html.append(" items.forEach(function(item) {");
html.append(" var name = item.getAttribute('data-name') || '';");
html.append(" if (!query || name.indexOf(query) !== -1) {");
html.append(" item.style.display = 'grid';");
html.append(" hasMatch = true;");
html.append(" } else {");
html.append(" item.style.display = 'none';");
html.append(" }");
html.append(" });");
html.append(" if (hasMatch) {");
html.append(" group.style.display = 'block';");
html.append(" if (query) {");
html.append(" catRow.classList.add('expanded');");
html.append(" itemList.classList.add('open');");
html.append(" }");
html.append(" } else {");
html.append(" group.style.display = 'none';");
html.append(" }");
html.append(" });");
html.append("}");
html.append("</script>");
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
}
@@ -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<RecruitingTable> 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<List<RecruitingTable>>() {});
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<RecruitingTable> parseTablesJson(String json) {
if (json == null || json.trim().isEmpty() || "[]".equals(json)) {
return new ArrayList<>();
}
try {
return objectMapper.readValue(json, new TypeReference<List<RecruitingTable>>() {});
} 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<RecruitingItem> items = new ArrayList<>();
public RecruitingTable() {}
public RecruitingTable(String id, String name, String description, String imageUrl, List<RecruitingItem> 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<RecruitingItem> getItems() { return items; }
public void setItems(List<RecruitingItem> items) { this.items = items; }
}
}
@@ -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 += "<a class=\"collapse-item\" href=\"/manage/plugins/recruiting-table\">Tuyển dụng Nhân sự</a>\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<RecruitingTableAdminController.RecruitingTable> defaultList = new ArrayList<>();
List<RecruitingTableAdminController.RecruitingItem> 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<RecruitingTableAdminController.RecruitingTable> 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 "<div class='alert alert-light text-center py-4 border rounded'>Chưa có thông tin tuyển dụng.</div>";
}
// 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<RecruitingTableAdminController.RecruitingItem> items = selectedTable.getItems() != null ? selectedTable.getItems() : new ArrayList<>();
StringBuilder html = new StringBuilder();
html.append("<style>");
html.append(".sis-recruiting-wrap-").append(tableId).append(" { font-family: inherit; width: 100% !important; max-width: 100% !important; margin: 1rem 0; display: block; }");
html.append(".sis-recruiting-layout-").append(tableId).append(" { display: flex; flex-wrap: wrap; gap: 2rem; align-items: flex-start; width: 100%; }");
html.append(".sis-recruiting-img-col-").append(tableId).append(" { flex: 0 0 320px; max-width: 380px; width: 100%; }");
html.append(".sis-recruiting-img-col-").append(tableId).append(" img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.08); object-fit: cover; }");
html.append(".sis-recruiting-table-col-").append(tableId).append(" { flex: 1 1 450px; width: 100%; min-width: 0; }");
html.append(".sis-recruiting-table-wrap-").append(tableId).append(" { width: 100% !important; max-width: 100% !important; display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }");
html.append(".sis-recruiting-table-").append(tableId).append(" { width: 100% !important; min-width: 580px; border-collapse: separate; border-spacing: 0; table-layout: auto; }");
html.append(".sis-recruiting-table-").append(tableId).append(" th { font-weight: 700; color: #334155; font-size: 0.95rem; border-bottom: 2px solid #e2e8f0; padding: 12px 14px; background: transparent; white-space: nowrap !important; }");
html.append(".sis-recruiting-table-").append(tableId).append(" td { padding: 14px 14px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; font-size: 0.95rem; white-space: nowrap !important; }");
html.append(".sis-recruiting-table-").append(tableId).append(" tr:last-child td { border-bottom: none; }");
html.append(".sis-recruiting-title-link { color: #007bff; font-weight: 700; text-decoration: none; font-size: 1.02rem; transition: color 0.2s; white-space: nowrap !important; }");
html.append(".sis-recruiting-title-link:hover { color: #0056b3; text-decoration: underline; }");
html.append(".sis-recruiting-detail-btn { color: #007bff; font-weight: 600; text-decoration: none; font-size: 0.9rem; display: inline-flex; align-items: center; transition: all 0.2s; white-space: nowrap !important; }");
html.append(".sis-recruiting-detail-btn:hover { color: #0056b3; transform: translateX(3px); }");
html.append(".sis-recruiting-header-icon { font-size: 0.75rem; margin-left: 4px; color: #007bff; }");
html.append("@media (max-width: 768px) { .sis-recruiting-table-").append(tableId).append(" th, .sis-recruiting-table-").append(tableId).append(" td { padding: 10px 8px; font-size: 0.85rem; white-space: nowrap !important; } }");
html.append("</style>");
html.append("<div class='sis-recruiting-wrap-").append(tableId).append("'>");
if (imageUrl != null && !imageUrl.trim().isEmpty()) {
html.append(" <div class='sis-recruiting-layout-").append(tableId).append("'>");
html.append(" <div class='sis-recruiting-img-col-").append(tableId).append("'>");
html.append(" <img src='").append(escapeHtml(imageUrl.trim())).append("' alt='").append(escapeHtml(selectedTable.getName())).append("' />");
html.append(" </div>");
html.append(" <div class='sis-recruiting-table-col-").append(tableId).append("'>");
}
html.append(" <div class='sis-recruiting-table-wrap-").append(tableId).append("'>");
html.append(" <table class='sis-recruiting-table-").append(tableId).append("'>");
html.append(" <thead>");
html.append(" <tr>");
html.append(" <th style='text-align: left; width: 100% !important; white-space: nowrap !important;'>Vị trí tuyển dụng <span class='sis-recruiting-header-icon'>˅</span></th>");
html.append(" <th style='text-align: center; white-space: nowrap !important;'>Số lượng <span class='sis-recruiting-header-icon'>˅</span></th>");
html.append(" <th style='text-align: center; white-space: nowrap !important;'>Thời hạn <span class='sis-recruiting-header-icon'>˅</span></th>");
html.append(" <th style='text-align: right; white-space: nowrap !important;'></th>");
html.append(" </tr>");
html.append(" </thead>");
html.append(" <tbody>");
if (items.isEmpty()) {
html.append(" <tr><td colspan='4' class='text-center text-muted py-4'>Hiện chưa có vị trí tuyển dụng nào.</td></tr>");
} 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(" <tr>");
html.append(" <td style='text-align: left; width: 100% !important; white-space: nowrap !important;'>");
String targetUrl = (!"#".equals(url) && !url.isEmpty()) ? url : "javascript:void(0);";
html.append(" <a href='").append(escapeHtml(targetUrl)).append("' class='sis-recruiting-title-link' style='white-space: nowrap !important; display: inline-block;'>").append(escapeHtml(pos)).append("</a>");
html.append(" </td>");
html.append(" <td style='text-align: center; color: #6c757d; font-weight: 600; white-space: nowrap !important;'>").append(escapeHtml(qty)).append("</td>");
html.append(" <td style='text-align: center; color: #6c757d; font-weight: 600; white-space: nowrap !important;'>").append(escapeHtml(dl)).append("</td>");
html.append(" <td style='text-align: right; white-space: nowrap !important;'>");
if (!"#".equals(url)) {
html.append(" <a href='").append(escapeHtml(url)).append("' class='sis-recruiting-detail-btn' style='white-space: nowrap !important;'>Xem chi tiết →</a>");
} else {
html.append(" <span class='sis-recruiting-detail-btn' style='opacity:0.6;cursor:default; white-space: nowrap !important;'>Xem chi tiết →</span>");
}
html.append(" </td>");
html.append(" </tr>");
}
}
html.append(" </tbody>");
html.append(" </table>");
html.append(" </div>");
if (imageUrl != null && !imageUrl.trim().isEmpty()) {
html.append(" </div>"); // end table-col
html.append(" </div>"); // end layout
}
html.append("</div>");
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
}
@@ -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<HtmlSnippet> findAll() {
return snippetRepository.findAll();
}
@Transactional(readOnly = true)
public Optional<HtmlSnippet> 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<HtmlSnippet> 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 &#91; and &#93; in case the WYSIWYG editor escaped them
java.util.regex.Matcher m = java.util.regex.Pattern.compile("(?:\\[|&#91;)menu:([a-zA-Z0-9_-]+)(?::([a-zA-Z0-9_-]+))?(?:\\]|&#93;)").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(
"(?:\\[|&#91;)component:([a-zA-Z0-9_-]+)(?:\\s+data-source=\"([^\"]+)\")?(?:\\]|&#93;)"
).matcher(content);
@@ -109,7 +108,7 @@ public class HtmlSnippetService {
content = (String) hookManager.applyFilters("snippet_content", content);
return "\n<!-- BEGIN Snippet: " + slug + " -->\n" + content + "\n<!-- END Snippet: " + slug + " -->\n";
return "\n<!-- BEGIN Snippet: " + trimmedSlug + " -->\n" + content + "\n<!-- END Snippet: " + trimmedSlug + " -->\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<WidgetDto> widgets = objectMapper.readValue(json, new TypeReference<List<WidgetDto>>() {});
StringBuilder sb = new StringBuilder();
for (WidgetDto w : widgets) {
sb.append("<div class=\"card shadow-sm mb-4\">");
if (w.getTitle() != null && !w.getTitle().isEmpty()) {
sb.append("<div class=\"card-header bg-white font-weight-bold\">").append(w.getTitle()).append("</div>");
}
sb.append("<div class=\"card-body\">").append(w.getContent()).append("</div>");
sb.append("</div>");
}
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);
}
}
@@ -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;
}
@@ -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 = '<i class="fas fa-arrows-alt-h"></i> 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 = '<i class="fas fa-arrows-alt-h"></i> 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: '<svg width="20" height="20" viewBox="0 0 24 24" fill="red"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>'
};
}
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 = '<i class="fab fa-youtube"></i> 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 = '<i class="fas fa-image"></i> 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 = '<svg width="68" height="48" viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="#ff0000"/><path d="M45 24L27 14v20z" fill="#ffffff"/></svg>';
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 = '<i class="fas fa-arrows-alt-h"></i> 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: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>'
};
}
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 = '<i class="fas fa-images fa-2x mb-2 text-primary"></i><span>Chọn ảnh từ Thư viện (Media Library)</span>';
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 = '<i class="fas fa-cog mr-1"></i> 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) {
@@ -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: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>'
};
}
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 = `
<option value="">-- Chọn Shortcode Plugin --</option>
<option value="[plugin:recruiting-table]">Bảng Tuyển dụng Mặc định [plugin:recruiting-table]</option>
<option value='[plugin:recruiting-table id="recruiting_1"]'>Bảng Tuyển dụng theo ID [plugin:recruiting-table id="recruiting_1"]</option>
<option value='[plugin:recruiting-table id="recruiting_2"]'>Bảng Tuyển dụng theo ID [plugin:recruiting-table id="recruiting_2"]</option>
<option value="[plugin:price-table]">Bảng giá Mặc định [plugin:price-table]</option>
<option value='[plugin:price-table id="table_1"]'>Bảng giá theo ID [plugin:price-table id="table_1"]</option>
<option value="custom">-- Nhập Shortcode Tùy chỉnh --</option>
`;
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
};
@@ -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: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>'
};
}
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 = '<i class="fas fa-images fa-2x mb-2 text-primary"></i><span>Chọn ảnh từ Thư viện (Media Library)</span>';
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 = '<i class="fas fa-cog mr-1"></i> 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
};
@@ -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 = '<label class="small font-weight-bold text-secondary mb-1">Flex Items</label>';
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 = '<i class="fas fa-plus"></i> + 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 = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Custom Attributes</label>';
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 = `<span><i class="fas fa-ellipsis-v mr-1"></i> Tùy chọn Item ${i}</span><span style="cursor:pointer;" class="text-danger close-btn">&times;</span>`;
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 = '<i class="fas fa-copy text-info mr-2"></i> 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 = '<i class="fas fa-check mr-2"></i> Đã 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 = '<i class="fas fa-paste text-success mr-2"></i> 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 = '<i class="fas fa-check mr-2"></i> Đã 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 = '<i class="fas fa-clone text-primary mr-2"></i> 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 = '<i class="fas fa-check mr-2"></i> Đã 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 = '<i class="fas fa-arrow-left"></i> 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 <i class="fas fa-arrow-right"></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 = '<i class="fas fa-plus-circle mr-2"></i> 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 = '<i class="fas fa-trash-alt mr-2"></i> 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 = `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.65;"><circle cx="9" cy="5" r="2.2"/><circle cx="9" cy="12" r="2.2"/><circle cx="9" cy="19" r="2.2"/><circle cx="15" cy="5" r="2.2"/><circle cx="15" cy="12" r="2.2"/><circle cx="15" cy="19" r="2.2"/></svg>`;
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 = '<i class="fas fa-plus mr-1"></i> + 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 = '<i class="fab fa-youtube"></i> Đườ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 = '<i class="fas fa-image"></i> Ả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 = '<i class="fas fa-images mr-1"></i> 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 = '<i class="fas fa-arrows-alt-h text-primary"></i> É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}`] || '';
@@ -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 = '<label class="small font-weight-bold text-secondary mb-1">Columns</label>';
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 = '<i class="fas fa-plus"></i> + 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 = '<label class="small font-weight-bold text-secondary mb-1">Grid HTML ID</label>';
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 = `<span><i class="fas fa-ellipsis-v mr-1"></i> Tùy chọn Cột ${i}</span><span style="cursor:pointer;" class="text-danger close-btn">&times;</span>`;
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 = '<i class="fas fa-copy text-info mr-2"></i> 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 = '<i class="fas fa-check mr-2"></i> Đã 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 = '<i class="fas fa-paste text-success mr-2"></i> 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 = '<i class="fas fa-check mr-2"></i> Đã 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 = '<i class="fas fa-clone text-primary mr-2"></i> 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 = '<i class="fas fa-check mr-2"></i> Đã 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 = '<i class="fas fa-arrow-left"></i> 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 <i class="fas fa-arrow-right"></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 = '<i class="fas fa-plus-circle mr-2"></i> 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 = '<i class="fas fa-trash-alt mr-2"></i> 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 = `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.65;"><circle cx="9" cy="5" r="2.2"/><circle cx="9" cy="12" r="2.2"/><circle cx="9" cy="19" r="2.2"/><circle cx="15" cy="5" r="2.2"/><circle cx="15" cy="12" r="2.2"/><circle cx="15" cy="19" r="2.2"/></svg>`;
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 = '<i class="fas fa-plus mr-1"></i> + 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 = '<i class="fab fa-youtube"></i> Đườ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 = '<i class="fas fa-image"></i> Ả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 = '<i class="fas fa-images mr-1"></i> 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 = '<i class="fas fa-arrows-alt-h text-primary"></i> É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}`] || '';
@@ -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;
@@ -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' },
@@ -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'];
@@ -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: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 6v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6"></path><path d="M4 10h16"></path><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"></path></svg>'
};
}
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 = '<div class="font-weight-bold text-primary small"><i class="fas fa-folder mr-1"></i> Multi-Tab Panel Settings</div>';
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 = '<i class="fas fa-plus mr-1"></i> 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 = '<label class="small font-weight-bold text-secondary mb-1">CSS Class (Wrapper)</label>';
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 = '<label class="small font-weight-bold text-secondary mb-1">HTML ID</label>';
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 = '<label class="small font-weight-bold text-secondary mb-1">Inline Style</label>';
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 = '<label class="small font-weight-bold text-secondary mb-1">Custom Attributes</label>';
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 = '<i class="fas fa-plus mr-1"></i> + 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'];
@@ -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'];
@@ -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'];
@@ -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 = '<i class="fas fa-copy mr-1"></i> 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 = '<i class="fas fa-check mr-1"></i> Đã sao chép khối!';
setTimeout(() => {
copyBtn.className = 'btn btn-sm btn-outline-primary btn-block text-left mb-1';
copyBtn.innerHTML = '<i class="fas fa-copy mr-1"></i> 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 = '<i class="fas fa-paste mr-1"></i> 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 = '<i class="fas fa-check mr-1"></i> Đã 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 = '<i class="fas fa-paste mr-1"></i> 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 = '<i class="fas fa-palette"></i> Style & Alignment';
@@ -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();
});
@@ -0,0 +1,241 @@
(function() {
'use strict';
class SISYouTubeTool {
static get toolbox() {
return {
title: 'YouTube Video',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="red"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>'
};
}
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 = '<i class="fab fa-youtube"></i> 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 = '<i class="fas fa-image"></i> Ả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 = '<i class="fas fa-images mr-1"></i> 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 = '<svg width="68" height="48" viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="#ff0000"/><path d="M45 24L27 14v20z" fill="#ffffff"/></svg>';
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 = '<i class="fas fa-arrows-alt-h"></i> 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;
})();
@@ -78,120 +78,155 @@
/* ── Body ─── */
'<div class="modal-body bg-light p-3" style="max-height: 85vh; overflow-y: auto;">' +
/* Toolbar */
'<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 bg-white p-2 border rounded shadow-sm">' +
'<div class="input-group input-group-sm mb-2 mb-md-0" style="max-width: 380px;">' +
'<div class="input-group-prepend"><span class="input-group-text bg-light"><i class="fas fa-search text-muted"></i></span></div>' +
'<input type="text" class="form-control" id="sisMediaSearchInput" placeholder="Tìm kiếm theo tên file..." autocomplete="off" />' +
'</div>' +
'<div class="d-flex align-items-center">' +
'<button type="button" class="btn btn-sm btn-outline-secondary mr-2" id="sisMediaRefreshBtn" title="Tải lại danh sách">' +
'<i class="fas fa-sync-alt"></i>' +
/* Config Panel (Positioned at top for fast editing of pre-selected images) */
'<div id="' + this.configPanelId + '" class="mb-3 p-3 bg-white border rounded shadow-sm" style="display: none;">' +
'<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">' +
'<h6 class="font-weight-bold text-primary m-0"><i class="fas fa-sliders-h mr-1"></i> Cấu hình hình ảnh đã chọn</h6>' +
'<button type="button" class="btn btn-sm btn-outline-primary shadow-sm" id="sisMediaChangeImageBtn">' +
'<i class="fas fa-exchange-alt mr-1"></i> Đổi sang ảnh khác từ thư viện' +
'</button>' +
'<label class="btn btn-sm btn-success mb-0 shadow-sm" style="cursor: pointer;">' +
'<i class="fas fa-cloud-upload-alt mr-1"></i> Tải ảnh mới lên' +
'<input type="file" id="sisMediaUploadInput" accept="image/*" style="display: none;" />' +
'</label>' +
'</div>' +
'</div>' +
/* Info bar */
'<div id="sisMediaInfoBar" class="small text-muted mb-2 px-1" style="min-height: 18px;"></div>' +
/* Grid */
'<div id="sisMediaGrid" class="row" style="max-height: 340px; overflow-y: auto; align-content: start;">' +
'<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải...</span></div>' +
'</div>' +
/* Lazy-load sentinel */
'<div id="sisMediaSentinel" style="height: 1px;"></div>' +
/* Config Panel */
'<div id="' + this.configPanelId + '" class="mt-3 p-3 bg-white border rounded shadow-sm" style="display: none;">' +
'<h6 class="font-weight-bold text-primary mb-3"><i class="fas fa-sliders-h mr-1"></i> Cấu hình hình ảnh đã chọn</h6>' +
'<div class="row">' +
/* Preview */
'<div class="col-md-3 text-center mb-3">' +
'<p class="small text-muted mb-1 font-weight-bold">Xem trước</p>' +
'<div class="border rounded p-2 bg-light" style="min-height: 100px; display: flex; align-items: center; justify-content: center;">' +
'<div class="border rounded p-2 bg-light mb-2" style="min-height: 100px; display: flex; align-items: center; justify-content: center;">' +
'<img id="sisMediaConfigPreview" src="" alt="Preview" style="max-width: 100%; max-height: 150px; object-fit: contain;" />' +
'</div>' +
'<p id="sisMediaConfigName" class="small text-truncate mt-1 text-muted mb-0"></p>' +
'<p id="sisMediaConfigName" class="small text-truncate mt-1 text-muted mb-2"></p>' +
'<button type="button" class="btn btn-xs btn-outline-secondary w-100 mb-1" id="sisMediaChangeImageBtn2">' +
'<i class="fas fa-images mr-1"></i> Chọn ảnh khác từ thư viện' +
'</button>' +
'</div>' +
/* Config fields */
'<div class="col-md-9">' +
'<div class="row">' +
/* Quick sizes */
'<div class="col-12 mb-2">' +
'<label class="small font-weight-bold text-muted d-block mb-1" style="font-size:10px;text-transform:uppercase;">Kích thước nhanh</label>' +
'<div id="sisMediaSizeButtons" class="d-flex flex-wrap" style="gap:4px;"></div>' +
'</div>' +
/* Width */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Chiều rộng (width)</label>' +
'<input type="text" id="sisMediaConfigWidth" class="form-control form-control-sm" placeholder="e.g. 100%, 300px, auto" />' +
'</div>' +
/* Height */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Chiều cao (height)</label>' +
'<input type="text" id="sisMediaConfigHeight" class="form-control form-control-sm" placeholder="e.g. auto, 200px" />' +
'</div>' +
/* Object Fit */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Object Fit</label>' +
'<select id="sisMediaConfigObjectFit" class="form-control form-control-sm">' +
'<option value="">-- default --</option>' +
'<option value="cover">cover</option>' +
'<option value="contain">contain</option>' +
'<option value="fill">fill</option>' +
'<option value="none">none</option>' +
'<option value="scale-down">scale-down</option>' +
'</select>' +
'</div>' +
/* Aspect Ratio */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Aspect Ratio</label>' +
'<select id="sisMediaConfigAspectRatio" class="form-control form-control-sm">' +
'<option value="">-- none --</option>' +
'<option value="1/1">1:1 (Square)</option>' +
'<option value="4/3">4:3 (Standard)</option>' +
'<option value="16/9">16:9 (Widescreen)</option>' +
'<option value="21/9">21:9 (Ultrawide)</option>' +
'<option value="3/4">3:4 (Portrait)</option>' +
'<option value="9/16">9:16 (Vertical Video)</option>' +
'<option value="2/1">2:1 (Panorama)</option>' +
'<option value="3/2">3:2 (Photo)</option>' +
'<option value="5/4">5:4</option>' +
'</select>' +
'</div>' +
/* CSS Class */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">CSS Class</label>' +
'<input type="text" id="sisMediaConfigClass" class="form-control form-control-sm" placeholder="e.g. img-fluid rounded shadow" />' +
'</div>' +
/* Inline Style */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Inline Style (CSS)</label>' +
'<input type="text" id="sisMediaConfigStyle" class="form-control form-control-sm" placeholder="e.g. border-radius: 8px; opacity: 0.9;" />' +
'</div>' +
/* Alt Text */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Alt Text</label>' +
'<input type="text" id="sisMediaConfigAlt" class="form-control form-control-sm" placeholder="Image description..." />' +
'</div>' +
/* Custom Attributes */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Custom Attributes (HTML)</label>' +
'<input type="text" id="sisMediaConfigAttrs" class="form-control form-control-sm" placeholder=\'e.g. data-id="5" loading="lazy"\' />' +
'</div>' +
'</div>' +
/* Quick sizes dropdown */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;text-transform:uppercase;">Kích thước nhanh</label>' +
'<select id="sisMediaConfigQuickSize" class="form-control form-control-sm">' +
'<option value="">-- none --</option>' +
'<option value="auto,auto">Auto</option>' +
'<option value="25%,auto">25%</option>' +
'<option value="50%,auto">50%</option>' +
'<option value="75%,auto">75%</option>' +
'<option value="100%,auto">100%</option>' +
'<option value="100px,auto">100px</option>' +
'<option value="150px,auto">150px</option>' +
'<option value="200px,auto">200px</option>' +
'<option value="300px,auto">300px</option>' +
'<option value="400px,auto">400px</option>' +
'<option value="500px,auto">500px</option>' +
'<option value="100%,auto">Full Width (100%)</option>' +
'<option value="80px,80px">Thumb (80×80)</option>' +
'<option value="120px,120px">Avatar (120×120)</option>' +
'<option value="1200px,400px">Banner (1200×400)</option>' +
'</select>' +
'</div>' +
/* Width */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Chiều rộng (width)</label>' +
'<input type="text" id="sisMediaConfigWidth" class="form-control form-control-sm" placeholder="e.g. 100%, 300px, auto" />' +
'</div>' +
/* Height */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Chiều cao (height)</label>' +
'<input type="text" id="sisMediaConfigHeight" class="form-control form-control-sm" placeholder="e.g. auto, 200px" />' +
'</div>' +
/* Object Fit */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Object Fit</label>' +
'<select id="sisMediaConfigObjectFit" class="form-control form-control-sm">' +
'<option value="">-- default --</option>' +
'<option value="cover">cover</option>' +
'<option value="contain">contain</option>' +
'<option value="fill">fill</option>' +
'<option value="none">none</option>' +
'<option value="scale-down">scale-down</option>' +
'</select>' +
'</div>' +
/* Aspect Ratio */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Aspect Ratio</label>' +
'<select id="sisMediaConfigAspectRatio" class="form-control form-control-sm">' +
'<option value="">-- none --</option>' +
'<option value="1/1">1:1 (Square)</option>' +
'<option value="4/3">4:3 (Standard)</option>' +
'<option value="16/9">16:9 (Widescreen)</option>' +
'<option value="21/9">21:9 (Ultrawide)</option>' +
'<option value="3/4">3:4 (Portrait)</option>' +
'<option value="9/16">9:16 (Vertical Video)</option>' +
'<option value="2/1">2:1 (Panorama)</option>' +
'<option value="3/2">3:2 (Photo)</option>' +
'<option value="5/4">5:4</option>' +
'</select>' +
'</div>' +
/* Element ID */
'<div class="col-md-4 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Element ID</label>' +
'<input type="text" id="sisMediaConfigId" class="form-control form-control-sm" placeholder="e.g. media-img-1" />' +
'</div>' +
/* File Class (CSS Class) */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">File Class (CSS Class)</label>' +
'<input type="text" id="sisMediaConfigClass" class="form-control form-control-sm" placeholder="e.g. img-fluid rounded shadow" />' +
'</div>' +
/* Alt Text */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Alt Text</label>' +
'<input type="text" id="sisMediaConfigAlt" class="form-control form-control-sm" placeholder="Image description..." />' +
'</div>' +
/* Inline Style */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Inline Style (CSS)</label>' +
'<input type="text" id="sisMediaConfigStyle" class="form-control form-control-sm" placeholder="e.g. border-radius: 8px; opacity: 0.9;" />' +
'</div>' +
/* Custom Attributes */
'<div class="col-md-6 mb-2">' +
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Custom Attributes (HTML)</label>' +
'<input type="text" id="sisMediaConfigAttrs" class="form-control form-control-sm" placeholder=\'e.g. data-id="5" loading="lazy"\' />' +
'</div>' +
'</div>' +
'<div class="text-right mt-2">' +
'<button type="button" class="btn btn-sm btn-secondary mr-2" id="sisMediaConfigCancel">Hủy</button>' +
'<button type="button" class="btn btn-sm btn-primary" id="sisMediaConfigConfirm"><i class="fas fa-check mr-1"></i> Chọn ảnh này</button>' +
'<button type="button" class="btn btn-sm btn-primary shadow-sm" id="sisMediaConfigConfirm"><i class="fas fa-check mr-1"></i> Chọn / Cập nhật ảnh này</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
/* Library Grid Wrapper */
'<div id="sisMediaGridWrapper">' +
/* Toolbar */
'<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 bg-white p-2 border rounded shadow-sm">' +
'<div class="input-group input-group-sm mb-2 mb-md-0" style="max-width: 380px;">' +
'<div class="input-group-prepend"><span class="input-group-text bg-light"><i class="fas fa-search text-muted"></i></span></div>' +
'<input type="text" class="form-control" id="sisMediaSearchInput" placeholder="Tìm kiếm theo tên file..." autocomplete="off" />' +
'</div>' +
'<div class="d-flex align-items-center">' +
'<button type="button" class="btn btn-sm btn-outline-secondary mr-2" id="sisMediaRefreshBtn" title="Tải lại danh sách">' +
'<i class="fas fa-sync-alt"></i>' +
'</button>' +
'<label class="btn btn-sm btn-success mb-0 shadow-sm" style="cursor: pointer;">' +
'<i class="fas fa-cloud-upload-alt mr-1"></i> Tải ảnh mới lên' +
'<input type="file" id="sisMediaUploadInput" accept="image/*" style="display: none;" />' +
'</label>' +
'</div>' +
'</div>' +
/* Info bar */
'<div id="sisMediaInfoBar" class="small text-muted mb-2 px-1" style="min-height: 18px;"></div>' +
/* Grid */
'<div id="sisMediaGrid" class="row" style="max-height: 340px; overflow-y: auto; align-content: start;">' +
'<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải...</span></div>' +
'</div>' +
/* Lazy-load sentinel */
'<div id="sisMediaSentinel" style="height: 1px;"></div>' +
'</div>' +
'</div>' +
'</div>' +
/* 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') {
@@ -462,6 +462,10 @@
<script th:src="@{/js/manage/editor-plugins/text-styling.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tiny-mce.js}"></script>
<script th:src="@{/js/manage/editor-plugins/posts.js}"></script>
<script th:src="@{/js/manage/editor-plugins/tab-plugin.js}"></script>
<script th:src="@{/js/manage/editor-plugins/custom-image.js}"></script>
<script th:src="@{/js/manage/editor-plugins/cms_plugin.js}"></script>
<script th:src="@{/js/manage/editor-plugins/youtube-plugin.js}"></script>
<!-- SIS Standalone Media Picker Library -->
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
@@ -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('<p>&nbsp;</p><hr class="dynamic-tab-end"/><p>&nbsp;</p>');
},
});
// ─── 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: [
'<div style="padding:4px 0 12px 0">',
'<p style="color:#555;margin:0 0 10px 0;font-size:13px;">',
'📌 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.',
'</p>',
'<textarea id="sisTabNamesInput" rows="5" style="width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;font-size:13px;font-family:sans-serif;resize:vertical;" placeholder="Tổng quan&#10;Thông tin chung&#10;Lịch khai giảng&#10;Học phí"></textarea>',
'<div style="margin-top:8px">',
'<label style="font-size:12px;color:#666;font-weight:600;">🎨 Màu nền Tab header:</label>',
'<input type="color" id="sisTabColorInput" value="#881c1c" style="margin-left:8px;width:40px;height:28px;border:none;padding:0;cursor:pointer;" />',
'<span id="sisTabColorHex" style="margin-left:6px;font-size:12px;font-family:monospace;color:#555;">#881c1c</span>',
'</div>',
'</div>',
].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 += '<button type="button"'
+ ' onclick="(function(btn){var wrap=btn.closest(\'[data-sis-tabs=\\\'' + uid + '\\\']\');wrap.querySelectorAll(\'[data-sis-tab-btn]\').forEach(function(b){b.style.background=\'#fff\';b.style.color=\'' + color + '\';});btn.style.background=\'' + color + '\';btn.style.color=\'#fff\';wrap.querySelectorAll(\'[data-sis-tab-panel]\').forEach(function(p){p.style.display=\'none\';});wrap.querySelector(\'[data-sis-tab-panel=\\\'' + i + '\\\']\').style.display=\'block\';})(this)"'
+ ' data-sis-tab-btn="' + i + '"'
+ ' style="' + btnStyle + 'padding:10px 20px;cursor:pointer;font-weight:700;font-size:14px;flex:1 1 auto;transition:all 0.2s;">'
+ name
+ '</button>';
tabPanelsHtml += '<div data-sis-tab-panel="' + i + '" style="display:' + (isActive ? 'block' : 'none') + ';padding:20px 16px;border:2px solid ' + color + ';border-top:none;border-radius:0 0 6px 6px;min-height:120px;">'
+ '<p>Nội dung Tab <strong>' + name + '</strong> — hãy thay thế dòng này (có thể chèn bảng, hình ảnh, danh sách...).</p>'
+ '</div>';
});
var fullHtml = '<div data-sis-tabs="' + uid + '" class="sis-multi-tab-wrapper" style="margin:24px 0;">'
+ '<div style="display:flex;flex-wrap:wrap;">' + tabBtnsHtml + '</div>'
+ tabPanelsHtml
+ '</div>'
+ '<p>&nbsp;</p>';
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',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,420 @@
<html
lang="vi"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{fragments/manage-layout}"
>
<head>
<title th:text="${pageTitle} + ' - SIS Vietnam'">Bảng giá Dịch vụ Settings</title>
<style>
.shortcode-badge {
font-family: monospace;
background: #eef2ff;
color: #4338ca;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #c7d2fe;
font-weight: 600;
font-size: 0.88rem;
}
.cat-card-modal {
border: 1px solid #e3e6f0;
border-left: 4px solid #4e73df;
margin-bottom: 1.25rem;
padding: 1rem;
border-radius: 6px;
background: #f8fafc;
}
.item-row-modal {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 0.6rem;
margin-bottom: 0.5rem;
}
.copy-btn {
cursor: pointer;
transition: transform 0.1s;
}
.copy-btn:active {
transform: scale(0.95);
}
</style>
</head>
<body>
<div layout:fragment="content">
<div class="container-fluid">
<!-- Header -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800" th:text="${pageTitle}">Quản lý Đa Bảng giá Dịch vụ</h1>
<div>
<button type="button" class="btn btn-success font-weight-bold" onclick="openAddModal()">
<i class="fas fa-plus-circle mr-1"></i>
</button>
</div>
</div>
<!-- System Alerts -->
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${successMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<!-- Main Form -->
<form method="post" th:action="@{/manage/plugins/price-table}" id="priceForm">
<input type="hidden" name="tableData" id="tableData" />
<!-- Price Tables List View -->
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex justify-content-between align-items-center bg-white text-primary">
<h6 class="m-0 font-weight-bold"><i class="fas fa-list mr-2"></i> Danh sách các Bảng Giá trong hệ thống</h6>
<button type="submit" class="btn btn-light btn-sm font-weight-bold text-primary px-3" onclick="return saveAllForms();">
<i class="fas fa-save mr-1"></i>
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered table-hover mb-0" id="mainTablesList">
<thead class="thead-light">
<tr>
<th style="width: 50px" class="text-center">STT</th>
<th style="width: 140px">ID (Slug)</th>
<th>Tên Bảng Giá</th>
<th>Tên Tabs</th>
<th style="width: 120px" class="text-center">Nhóm / Dịch vụ</th>
<th style="width: 320px">Shortcode nhúng</th>
<th style="width: 160px" class="text-center">Thao tác</th>
</tr>
</thead>
<tbody id="tablesTableBody">
<!-- Populated dynamically by JS -->
</tbody>
</table>
</div>
</div>
</div>
</form>
</div>
<!-- Edit / Add Modal Popup -->
<div
class="modal fade"
id="tableEditModal"
tabindex="-1"
role="dialog"
aria-labelledby="modalTitle"
aria-hidden="true"
data-backdrop="static"
>
<div class="modal-dialog modal-xl modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title font-weight-bold" id="modalTitle">Chỉnh sửa Bảng giá</h5>
<button type="button" class="close text-white" onclick="closeModal()" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" style="background-color: #f8fafc">
<input type="hidden" id="modalTableIndex" value="-1" />
<!-- Table General Settings -->
<div class="card mb-4 shadow-sm">
<div class="card-body">
<div class="form-row">
<div class="col-md-3 mb-3">
<label class="font-weight-bold text-primary">ID Bảng giá (Slug)</label>
<input type="text" id="modalTableId" class="form-control" placeholder="VD: kham-benh" />
<small class="form-text text-muted">Dùng cho shortcode: <code>[plugin:price-table id="..."]</code></small>
</div>
<div class="col-md-3 mb-3">
<label class="font-weight-bold text-primary">Tên Bảng giá</label>
<input type="text" id="modalTableName" class="form-control" placeholder="VD: Bảng giá khám chữa bệnh" />
</div>
<div class="col-md-3 mb-3">
<label class="font-weight-bold text-primary">Tên Tab 1</label>
<input type="text" id="modalTab1Name" class="form-control" placeholder="Mặc định: Bảng giá" />
</div>
<div class="col-md-3 mb-3">
<label class="font-weight-bold text-primary">Tên Tab 2</label>
<input type="text" id="modalTab2Name" class="form-control" placeholder="Mặc định: Danh mục kỹ thuật" />
</div>
<div class="col-md-12 mb-2">
<label class="font-weight-bold text-danger"
><i class="far fa-file-pdf mr-1"></i> URL File PDF cho Tab 2 (Danh mục kỹ thuật)</label
>
<input type="text" id="modalPdfUrl" class="form-control" placeholder="VD: /uploads/files/danh-muc-ky-thuat.pdf" />
<small class="form-text text-muted">File PDF hiển thị khi chuyển sang Tab 2 (Danh mục kỹ thuật).</small>
</div>
</div>
</div>
</div>
<!-- Categories & Items Editor -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="font-weight-bold text-dark m-0">
<i class="fas fa-folder mr-1"></i> Các nhóm danh mục & dịch vụ kỹ thuật (Tab 1)
</h6>
<button type="button" class="btn btn-success btn-sm font-weight-bold" onclick="addCategoryInModal()">
<i class="fas fa-folder-plus mr-1"></i> + Thêm Nhóm Dịch Vụ Mới
</button>
</div>
<div id="modalCategoriesContainer"></div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary font-weight-bold" onclick="closeModal()">Hủy bỏ</button>
<button type="button" class="btn btn-primary font-weight-bold px-4" onclick="saveModalChanges()">
<i class="fas fa-check mr-1"></i> Cập Nhật Vào Danh Sách
</button>
</div>
</div>
</div>
</div>
<!-- JavaScript logic -->
<script th:inline="javascript">
let tables = /*[[${tables}]]*/ [];
if (!tables) tables = [];
let currentModalCategories = [];
function renderMainTablesList() {
const tbody = document.getElementById('tablesTableBody');
tbody.innerHTML = '';
if (tables.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">Chưa có bảng giá nào. Hãy bấm <b>"+ Thêm Bảng Giá Mới"</b> để tạo.</td></tr>`;
return;
}
tables.forEach((tbl, idx) => {
const tr = document.createElement('tr');
const tableId = tbl.id || 'table_' + (idx + 1);
const categoriesCount = (tbl.categories || []).length;
let itemsCount = 0;
(tbl.categories || []).forEach(c => (itemsCount += (c.items || []).length));
const shortcodeDefault = `[plugin:price-table]`;
const shortcodeId = `[plugin:price-table id="${escapeAttr(tableId)}"]`;
tr.innerHTML = `
<td class="text-center font-weight-bold align-middle">${idx + 1}</td>
<td class="align-middle"><code class="font-weight-bold text-primary">${escapeAttr(tableId)}</code></td>
<td class="align-middle font-weight-bold text-dark">${escapeAttr(tbl.name || 'Bảng giá chưa đặt tên')}</td>
<td class="align-middle"><small class="text-muted">${escapeAttr(tbl.tab1Name || 'Bảng giá')} / ${escapeAttr(tbl.tab2Name || 'Danh mục kỹ thuật')}</small></td>
<td class="text-center align-middle"><span class="badge badge-secondary p-1">${categoriesCount} nhóm / ${itemsCount} dv</span></td>
<td class="align-middle">
<div class="d-flex flex-column gap-1">
${idx === 0 ? `<div class="mb-1"><small class="text-muted mr-1">Mặc định:</small> <span class="shortcode-badge">${shortcodeDefault}</span> <button type="button" class="btn btn-link btn-sm p-0 ml-1 copy-btn" onclick="copyText('${shortcodeDefault}')" title="Copy"><i class="far fa-copy"></i></button></div>` : ''}
<div><small class="text-muted mr-1">Theo ID:</small> <span class="shortcode-badge">${shortcodeId}</span> <button type="button" class="btn btn-link btn-sm p-0 ml-1 copy-btn" onclick="copyText('${shortcodeId}')" title="Copy"><i class="far fa-copy"></i></button></div>
</div>
</td>
<td class="text-center align-middle">
<button type="button" class="btn btn-primary btn-sm mr-1 font-weight-bold" onclick="openEditModal(${idx})" title="Chỉnh sửa">
<i class="fas fa-edit"></i> Sửa
</button>
<button type="button" class="btn btn-danger btn-sm font-weight-bold" onclick="deleteTable(${idx})" title="Xóa">
<i class="fas fa-trash-alt"></i> Xóa
</button>
</td>
`;
tbody.appendChild(tr);
});
}
/* --- Modal Functions --- */
function openAddModal() {
document.getElementById('modalTableIndex').value = -1;
const newId = 'table_' + (tables.length + 1);
document.getElementById('modalTableId').value = newId;
document.getElementById('modalTableName').value = 'Bảng giá mới ' + (tables.length + 1);
document.getElementById('modalTab1Name').value = 'Bảng giá';
document.getElementById('modalTab2Name').value = 'Danh mục kỹ thuật';
document.getElementById('modalPdfUrl').value = '';
document.getElementById('modalTitle').innerText = 'Tạo Bảng Giá Mới';
currentModalCategories = [{ categoryName: 'KHÁM BỆNH', items: [] }];
renderModalCategories();
$('#tableEditModal').modal('show');
}
function openEditModal(idx) {
const tbl = tables[idx];
document.getElementById('modalTableIndex').value = idx;
document.getElementById('modalTableId').value = tbl.id || '';
document.getElementById('modalTableName').value = tbl.name || '';
document.getElementById('modalTab1Name').value = tbl.tab1Name || 'Bảng giá';
document.getElementById('modalTab2Name').value = tbl.tab2Name || 'Danh mục kỹ thuật';
document.getElementById('modalPdfUrl').value = tbl.pdfUrl || '';
document.getElementById('modalTitle').innerText = 'Chỉnh Sửa Bảng Giá: ' + (tbl.name || '');
currentModalCategories = JSON.parse(JSON.stringify(tbl.categories || []));
renderModalCategories();
$('#tableEditModal').modal('show');
}
function closeModal() {
$('#tableEditModal').modal('hide');
}
function renderModalCategories() {
const container = document.getElementById('modalCategoriesContainer');
container.innerHTML = '';
if (currentModalCategories.length === 0) {
container.innerHTML =
'<div class="alert alert-info text-center">Chưa có nhóm dịch vụ nào. Bấm <b>"+ Thêm Nhóm Dịch Vụ Mới"</b> để tạo nhóm đầu tiên.</div>';
return;
}
currentModalCategories.forEach((cat, cIdx) => {
const card = document.createElement('div');
card.className = 'cat-card-modal shadow-sm';
let itemsHtml = '';
(cat.items || []).forEach((item, iIdx) => {
itemsHtml += `
<div class="item-row-modal">
<div class="form-row align-items-center">
<div class="col-md-2 mb-1">
<label class="small font-weight-bold text-muted m-0">Mã kỹ thuật</label>
<input type="text" class="form-control form-control-sm" value="${escapeAttr(item.code || '')}" onchange="updateModalItem(${cIdx}, ${iIdx}, 'code', this.value)" placeholder="VD: KB01" />
</div>
<div class="col-md-4 mb-1">
<label class="small font-weight-bold text-muted m-0">Tên dịch vụ kỹ thuật</label>
<input type="text" class="form-control form-control-sm" value="${escapeAttr(item.name || '')}" onchange="updateModalItem(${cIdx}, ${iIdx}, 'name', this.value)" placeholder="Tên dịch vụ..." />
</div>
<div class="col-md-2 mb-1">
<label class="small font-weight-bold text-muted m-0">Mức thu (Giá)</label>
<input type="text" class="form-control form-control-sm" value="${escapeAttr(item.unitPrice || '')}" onchange="updateModalItem(${cIdx}, ${iIdx}, 'unitPrice', this.value)" placeholder="VD: 150.000 đ" />
</div>
<div class="col-md-2 mb-1">
<label class="small font-weight-bold text-muted m-0">Mức BHYT thanh toán</label>
<input type="text" class="form-control form-control-sm" value="${escapeAttr(item.bhytPrice || '')}" onchange="updateModalItem(${cIdx}, ${iIdx}, 'bhytPrice', this.value)" placeholder="VD: 38.700 đ" />
</div>
<div class="col-md-2 mb-1 text-right mt-3">
<button type="button" class="btn btn-outline-danger btn-sm" onclick="removeModalItem(${cIdx}, ${iIdx})">
<i class="fas fa-times"></i> Xóa
</button>
</div>
</div>
</div>
`;
});
card.innerHTML = `
<div class="d-flex justify-content-between align-items-center border-bottom pb-2 mb-3">
<div class="form-inline" style="flex: 1;">
<span class="font-weight-bold text-primary mr-2">Nhóm ${cIdx + 1}:</span>
<input type="text" class="form-control form-control-sm font-weight-bold" style="width: 70%;" value="${escapeAttr(cat.categoryName || '')}" onchange="updateModalCatName(${cIdx}, this.value)" placeholder="Tên nhóm (VD: KHÁM BỆNH)..." />
</div>
<button type="button" class="btn btn-danger btn-sm" onclick="removeModalCategory(${cIdx})">
<i class="fas fa-trash-alt"></i> Xóa Nhóm
</button>
</div>
<div>
${itemsHtml}
<button type="button" class="btn btn-outline-success btn-sm mt-2" onclick="addModalItem(${cIdx})">
<i class="fas fa-plus-circle"></i> + Thêm Dịch Vụ Vào Nhóm
</button>
</div>
`;
container.appendChild(card);
});
}
function addCategoryInModal() {
currentModalCategories.push({ categoryName: 'NHÓM DỊCH VỤ MỚI', items: [] });
renderModalCategories();
}
function removeModalCategory(cIdx) {
if (confirm('Bạn có chắc muốn xóa nhóm này?')) {
currentModalCategories.splice(cIdx, 1);
renderModalCategories();
}
}
function updateModalCatName(cIdx, val) {
currentModalCategories[cIdx].categoryName = val;
}
function addModalItem(cIdx) {
if (!currentModalCategories[cIdx].items) currentModalCategories[cIdx].items = [];
currentModalCategories[cIdx].items.push({ code: '', name: '', unitPrice: '', bhytPrice: '', note: '' });
renderModalCategories();
}
function removeModalItem(cIdx, iIdx) {
currentModalCategories[cIdx].items.splice(iIdx, 1);
renderModalCategories();
}
function updateModalItem(cIdx, iIdx, field, val) {
currentModalCategories[cIdx].items[iIdx][field] = val;
}
function saveModalChanges() {
const idx = parseInt(document.getElementById('modalTableIndex').value, 10);
const id = document.getElementById('modalTableId').value.trim() || 'table_' + Date.now();
const name = document.getElementById('modalTableName').value.trim() || 'Bảng giá';
const tab1Name = document.getElementById('modalTab1Name').value.trim() || 'Bảng giá';
const tab2Name = document.getElementById('modalTab2Name').value.trim() || 'Danh mục kỹ thuật';
const pdfUrl = document.getElementById('modalPdfUrl').value.trim();
const tableObj = {
id: id,
name: name,
tab1Name: tab1Name,
tab2Name: tab2Name,
pdfUrl: pdfUrl,
categories: currentModalCategories,
};
if (idx >= 0 && idx < tables.length) {
tables[idx] = tableObj;
} else {
tables.push(tableObj);
}
renderMainTablesList();
closeModal();
}
function deleteTable(idx) {
if (confirm('Bạn có chắc chắn muốn xóa bảng giá này?')) {
tables.splice(idx, 1);
renderMainTablesList();
}
}
function saveAllForms() {
document.getElementById('tableData').value = JSON.stringify(tables);
return true;
}
function copyText(str) {
navigator.clipboard.writeText(str).then(() => {
alert('Đã copy shortcode: ' + str);
});
}
function escapeAttr(str) {
if (!str) return '';
return str.replace(/"/g, '&quot;');
}
document.addEventListener('DOMContentLoaded', function () {
renderMainTablesList();
});
</script>
</div>
</body>
</html>
@@ -0,0 +1,436 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{fragments/manage-layout}">
<head>
<title>Quản lý Bảng Tuyển dụng Nhân sự</title>
</head>
<body>
<div layout:fragment="content">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">
<i class="fas fa-user-tie text-primary mr-2"></i>Quản lý Bảng Tuyển dụng Nhân sự
</h1>
<button type="button" class="btn btn-sm btn-success shadow-sm" id="btnAddTable">
<i class="fas fa-plus mr-1"></i>Thêm Bảng Tuyển dụng Mới
</button>
</div>
<!-- Flash Messages -->
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show shadow-sm" role="alert">
<i class="fas fa-check-circle mr-2"></i><span th:text="${successMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show shadow-sm" role="alert">
<i class="fas fa-exclamation-triangle mr-2"></i><span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<!-- Guide Card -->
<div class="card border-left-primary shadow mb-4">
<div class="card-body">
<div class="row align-items-center">
<div class="col">
<h6 class="font-weight-bold text-primary mb-1"><i class="fas fa-info-circle mr-1"></i> Hướng dẫn chèn Shortcode:</h6>
<p class="small text-muted mb-0">
Chèn vào bất kỳ trang hoặc bài viết bằng mã Shortcode:
<code class="bg-light px-2 py-1 rounded text-primary font-weight-bold">[plugin:recruiting-table]</code> (Bảng mặc định)
hoặc <code class="bg-light px-2 py-1 rounded text-primary font-weight-bold">[plugin:recruiting-table id="ID_BANG"]</code> (VD: <code>[plugin:recruiting-table id="recruiting_1"]</code>).
</p>
</div>
</div>
</div>
</div>
<!-- Form -->
<form id="recruitingForm" th:action="@{/manage/plugins/recruiting-table}" method="post">
<input type="hidden" name="tableData" id="tableDataInput"/>
<div id="tablesContainer">
<!-- Dynamic Tables Rendered Here by JS -->
</div>
<div class="card shadow mb-4">
<div class="card-body text-right">
<button type="button" class="btn btn-secondary mr-2" id="btnAddTable2">
<i class="fas fa-plus mr-1"></i>Thêm Bảng Mới
</button>
<button type="submit" class="btn btn-primary px-4 shadow-sm" id="btnSaveAll">
<i class="fas fa-save mr-1"></i>Lưu Tất Cả Cấu Hình
</button>
</div>
</div>
</form>
<!-- Item Edit Modal -->
<div class="modal fade" id="itemModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title font-weight-bold text-primary" id="itemModalLabel">Cập nhật Vị trí Tuyển dụng</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<input type="hidden" id="modal-tidx">
<input type="hidden" id="modal-iidx">
<div class="form-group">
<label class="small font-weight-bold">Vị trí tuyển dụng</label>
<input type="text" class="form-control" id="modal-pos" placeholder="VD: Bác sĩ Cấp cứu">
</div>
<div class="form-group">
<label class="small font-weight-bold">Số lượng</label>
<input type="text" class="form-control" id="modal-qty" placeholder="VD: 01">
</div>
<div class="form-group">
<label class="small font-weight-bold">Thời hạn (Hạn nộp)</label>
<input type="text" class="form-control" id="modal-dl" placeholder="VD: 31/08/2026">
</div>
<div class="form-group">
<label class="small font-weight-bold">Link Mô tả công việc (URL)</label>
<input type="text" class="form-control" id="modal-url" placeholder="VD: /tuyen-dung/bac-si-cap-cuu">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Hủy bỏ</button>
<button type="button" class="btn btn-primary" id="btnSaveItem">Lưu thay đổi</button>
</div>
</div>
</div>
</div>
<!-- Table Edit Modal -->
<div class="modal fade" id="tableModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title font-weight-bold text-primary">Cập nhật Thông tin Bảng</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<input type="hidden" id="modal-table-tidx">
<div class="form-group">
<label class="small font-weight-bold">Tên Bảng Tuyển dụng</label>
<input type="text" class="form-control" id="modal-table-name" placeholder="VD: Tuyển dụng Bác sĩ">
</div>
<div class="form-group">
<label class="small font-weight-bold">Mã ID Bảng (dùng trong Shortcode)</label>
<input type="text" class="form-control" id="modal-table-id" placeholder="VD: recruiting_1">
<small class="form-text text-muted">Phải là chuỗi không dấu, không khoảng trắng (vd: bang_1).</small>
</div>
<div class="form-group">
<label class="small font-weight-bold">Ảnh Đại Diện Bảng (tùy chọn)</label>
<div class="input-group">
<input type="text" class="form-control" id="modal-table-image" placeholder="URL Hình ảnh (hoặc Chọn từ Thư viện)">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="btnBrowseTableImage">
<i class="fas fa-image"></i> Chọn Ảnh
</button>
</div>
</div>
<div class="mt-2 text-center" id="preview-table-image-container" style="display: none;">
<img id="preview-table-image" src="" alt="Preview" style="max-height: 120px; border-radius: 4px; border: 1px solid #ddd; object-fit: cover; max-width: 100%;" />
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Hủy bỏ</button>
<button type="button" class="btn btn-primary" id="btnSaveTable">Lưu thay đổi</button>
</div>
</div>
</div>
</div>
</div>
<th:block layout:fragment="scripts">
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
<script th:inline="javascript">
/*<![CDATA[*/
var initialTables = /*[[${tablesJson}]]*/ '[]';
/*]]>*/
$(document).ready(function() {
var tablesData = [];
try {
tablesData = typeof initialTables === 'string' ? JSON.parse(initialTables) : initialTables;
} catch(e) {
tablesData = [];
}
if (!Array.isArray(tablesData) || tablesData.length === 0) {
tablesData = [{
id: 'recruiting_1',
name: 'Danh sách vị trí tuyển dụng Bác sĩ',
description: 'Nhu cầu tuyển dụng các vị trí Bác sĩ',
items: [
{ position: 'Bác sĩ Cấp cứu', quantity: '01', deadline: '31/08/2026', detailUrl: '#' },
{ position: 'Bác sĩ Huyết học', quantity: '01', deadline: '30/06/2026', detailUrl: '#' },
{ position: 'Bác sĩ Xét nghiệm', quantity: '01', deadline: '30/06/2026', detailUrl: '#' },
{ position: 'Bác sĩ Vi sinh', quantity: '01', deadline: '30/06/2026', detailUrl: '#' }
]
}];
}
function renderTables() {
var $container = $('#tablesContainer').empty();
if (tablesData.length === 0) {
$container.html('<div class="alert alert-light text-center py-5 border rounded"><i class="fas fa-folder-open fa-2x text-muted mb-2"></i><br/>Chưa có bảng tuyển dụng nào. Nhấn "Thêm Bảng Mới" để khởi tạo.</div>');
return;
}
tablesData.forEach(function(table, tIdx) {
var cardHtml = `
<div class="card shadow mb-4 table-card" data-tidx="${tIdx}">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between bg-light">
<h6 class="m-0 font-weight-bold text-primary mr-2" style="font-size: 1.1rem;">
<i class="fas fa-table mr-2"></i><span class="table-title-display">${escapeHtml(table.name || 'Bảng chưa có tên')}</span>
</h6>
<div>
<button type="button" class="btn btn-sm btn-outline-primary btn-edit-table" data-tidx="${tIdx}" title="Sửa thông tin Bảng">
<i class="fas fa-cog mr-1"></i>Sửa Thông Tin Bảng
</button>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-table ml-2" data-tidx="${tIdx}" title="Xóa Bảng Này">
<i class="fas fa-trash-alt mr-1"></i>Xóa Bảng Này
</button>
</div>
</div>
<div class="card-body">
<div class="mb-3 d-flex align-items-center">
<div class="mr-3">
<strong>Mã ID:</strong> <span class="text-danger">${escapeHtml(table.id || '')}</span>
</div>
<div>
<strong>Shortcode:</strong> <code class="bg-light px-2 py-1 rounded">[plugin:recruiting-table id="${escapeHtml(table.id || '')}"]</code>
</div>
</div>
${table.imageUrl ? `
<div class="mb-3 text-center">
<img src="${escapeHtml(table.imageUrl)}" style="max-height: 80px; border-radius: 4px; border: 1px solid #eee;" alt="Ảnh Bảng" />
</div>` : ''}
<div class="table-responsive">
<table class="table table-bordered table-sm items-table mb-2">
<thead class="bg-light">
<tr class="text-center small text-muted">
<th style="width: 35%;">Vị trí tuyển dụng</th>
<th style="width: 15%;">Số lượng</th>
<th style="width: 20%;">Thời hạn (Hạn nộp)</th>
<th style="width: 20%;">Link Chi tiết (URL)</th>
<th style="width: 10%;">Thao tác</th>
</tr>
</thead>
<tbody>
`;
var items = table.items || [];
if (items.length === 0) {
cardHtml += `<tr><td colspan="5" class="text-center text-muted small py-3">Chưa có vị trí tuyển dụng nào trong bảng này.</td></tr>`;
} else {
items.forEach(function(item, iIdx) {
cardHtml += `
<tr data-iidx="${iIdx}">
<td class="align-middle">${escapeHtml(item.position || '')}</td>
<td class="align-middle text-center">${escapeHtml(item.quantity || '')}</td>
<td class="align-middle text-center">${escapeHtml(item.deadline || '')}</td>
<td class="align-middle">
<a href="${escapeHtml(item.detailUrl || '#')}" target="_blank" class="text-truncate d-inline-block" style="max-width: 150px;" title="${escapeHtml(item.detailUrl || '')}">
${escapeHtml(item.detailUrl || '')}
</a>
</td>
<td class="text-center align-middle">
<button type="button" class="btn btn-xs btn-outline-primary btn-edit-item mr-1" title="Sửa vị trí này">
<i class="fas fa-edit"></i>
</button>
<button type="button" class="btn btn-xs btn-outline-danger btn-delete-item" title="Xóa vị trí này">
<i class="fas fa-trash-alt"></i>
</button>
</td>
</tr>
`;
});
}
cardHtml += `
</tbody>
</table>
</div>
<button type="button" class="btn btn-xs btn-outline-success btn-add-item mt-1" data-tidx="${tIdx}">
<i class="fas fa-plus mr-1"></i>Thêm Vị Trí Tuyển Dụng
</button>
</div>
</div>
`;
$container.append(cardHtml);
});
}
renderTables();
// Event: Add table
$('#btnAddTable, #btnAddTable2').click(function() {
var newId = 'recruiting_' + (tablesData.length + 1);
tablesData.push({
id: newId,
name: 'Bảng tuyển dụng mới ' + (tablesData.length + 1),
description: '',
imageUrl: '',
items: []
});
renderTables();
});
// Event: Show Edit Table Modal
$(document).on('click', '.btn-edit-table', function() {
var tIdx = $(this).data('tidx');
var table = tablesData[tIdx];
$('#modal-table-tidx').val(tIdx);
$('#modal-table-id').val(table.id || '');
$('#modal-table-name').val(table.name || '');
$('#modal-table-image').val(table.imageUrl || '');
if (table.imageUrl) {
$('#preview-table-image').attr('src', table.imageUrl);
$('#preview-table-image-container').show();
} else {
$('#preview-table-image-container').hide();
$('#preview-table-image').attr('src', '');
}
$('#tableModal').modal('show');
});
// Event: Save Table from Modal
$('#btnSaveTable').click(function() {
var tIdx = parseInt($('#modal-table-tidx').val(), 10);
var tId = $('#modal-table-id').val().trim();
var tName = $('#modal-table-name').val().trim();
var tImage = $('#modal-table-image').val().trim();
tablesData[tIdx].id = tId;
tablesData[tIdx].name = tName;
tablesData[tIdx].imageUrl = tImage;
renderTables();
$('#tableModal').modal('hide');
});
// Event: Browse Table Image with SISMediaPicker
$('#btnBrowseTableImage').click(function() {
if (typeof SISMediaPicker !== 'undefined') {
SISMediaPicker.open(function(url) {
$('#modal-table-image').val(url);
$('#preview-table-image').attr('src', url);
$('#preview-table-image-container').show();
});
} else {
alert('SISMediaPicker chưa được tải!');
}
});
// Event: Delete table
$(document).on('click', '.btn-delete-table', function() {
var tIdx = $(this).data('tidx');
if (confirm('Bạn có chắc chắn muốn xóa toàn bộ bảng tuyển dụng này không?')) {
tablesData.splice(tIdx, 1);
renderTables();
}
});
// Event: Show Add Item Modal
$(document).on('click', '.btn-add-item', function() {
var tIdx = $(this).data('tidx');
$('#modal-tidx').val(tIdx);
$('#modal-iidx').val('-1'); // -1 means new item
$('#modal-pos').val('');
$('#modal-qty').val('01');
$('#modal-dl').val('');
$('#modal-url').val('#');
$('#itemModalLabel').text('Thêm Vị trí Tuyển dụng mới');
$('#itemModal').modal('show');
});
// Event: Show Edit Item Modal
$(document).on('click', '.btn-edit-item', function() {
var $tr = $(this).closest('tr');
var $card = $(this).closest('.table-card');
var tIdx = $card.data('tidx');
var iIdx = $tr.data('iidx');
var item = tablesData[tIdx].items[iIdx];
$('#modal-tidx').val(tIdx);
$('#modal-iidx').val(iIdx);
$('#modal-pos').val(item.position || '');
$('#modal-qty').val(item.quantity || '');
$('#modal-dl').val(item.deadline || '');
$('#modal-url').val(item.detailUrl || '');
$('#itemModalLabel').text('Cập nhật Vị trí Tuyển dụng');
$('#itemModal').modal('show');
});
// Event: Save Item from Modal
$('#btnSaveItem').click(function() {
var tIdx = parseInt($('#modal-tidx').val(), 10);
var iIdx = parseInt($('#modal-iidx').val(), 10);
var newItem = {
position: $('#modal-pos').val().trim(),
quantity: $('#modal-qty').val().trim(),
deadline: $('#modal-dl').val().trim(),
detailUrl: $('#modal-url').val().trim()
};
if (iIdx === -1) {
// Add new
if (!tablesData[tIdx].items) tablesData[tIdx].items = [];
tablesData[tIdx].items.push(newItem);
} else {
// Update existing
tablesData[tIdx].items[iIdx] = newItem;
}
$('#itemModal').modal('hide');
renderTables();
});
// Event: Delete item row
$(document).on('click', '.btn-delete-item', function() {
var $tr = $(this).closest('tr');
var $card = $(this).closest('.table-card');
var tIdx = $card.data('tidx');
var iIdx = $tr.data('iidx');
if (confirm('Xóa vị trí tuyển dụng này?')) {
tablesData[tIdx].items.splice(iIdx, 1);
renderTables();
}
});
// Form Submit
$('#recruitingForm').submit(function() {
$('#tableDataInput').val(JSON.stringify(tablesData));
return true;
});
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
});
</script>
</th:block>
</body>
</html>
@@ -138,6 +138,7 @@
/* Mega Menu Layout columns */
.mega-menu-content {
min-height: 250px;
display: flex;
width: 100%;
max-width: 1200px;