feat: implement Google Form plugin and enhance TinyMCE editor with advanced configuration attributes

This commit is contained in:
2026-08-08 09:32:07 +07:00
parent 373ee63716
commit 57a5c0cce3
39 changed files with 6600 additions and 577 deletions
+138 -252
View File
@@ -3,278 +3,164 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trang Tin Tức - Bài Viết Nổi Bật</title> <title>Chia sẻ lời tri ân</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<style> <style>
/* ===================================================
1. RESET & BASIC STYLES
=================================================== */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial,
sans-serif;
}
body { body {
background-color: #f8fafc; background: linear-gradient(
padding: 40px 20px; 135deg,
display: flex; #1e3a8a 0%,
justify-content: center; #3b82f6 50%,
} #eff6ff 100%
);
/* =================================================== min-height: 100vh;
2. GRID LAYOUT CHÍNH (12 CỘT)
=================================================== */
.news-section {
max-width: 1200px;
width: 100%;
background-color: #ffffff;
padding: 24px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 24px;
}
/* Component thẻ Badge (TIN TỨC / SỰ KIỆN) */
.badge {
display: inline-block;
padding: 4px 8px;
background-color: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
border-radius: 4px;
letter-spacing: 0.5px;
}
.post-date {
font-size: 12px;
color: #94a3b8;
}
/* ===================================================
3. CỘT TRÁI - BÀI VIẾT NỔI BẬT (FEATURED POST)
=================================================== */
.featured-post {
grid-column: span 6; /* Chiếm 6/12 cột */
display: flex;
flex-direction: column;
gap: 16px;
}
.featured-img-box {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
}
.featured-img-box img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.featured-post:hover .featured-img-box img {
transform: scale(1.03);
}
.meta-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; justify-content: center;
} font-family:
system-ui,
.featured-title { -apple-system,
font-size: 22px; BlinkMacSystemFont,
font-weight: 700; "Segoe UI",
color: #0284c7; /* Màu xanh chủ đạo */ Roboto,
line-height: 1.3; Oxygen,
cursor: pointer; Ubuntu,
text-decoration: none; Cantarell,
} sans-serif;
.featured-title:hover {
color: #0369a1;
}
.featured-excerpt {
font-size: 14px;
color: #475569;
line-height: 1.6;
}
/* ===================================================
4. CỘT PHẢI - LƯỚI 4 BÀI NHỎ (GRID 2x2)
=================================================== */
.side-news-grid {
grid-column: span 6; /* Chiếm 6/12 cột còn lại */
display: grid;
grid-template-columns: repeat(2, 1fr); /* Tách thành 2 cột nhỏ */
gap: 20px;
}
.small-post {
display: flex;
flex-direction: column;
gap: 8px;
text-decoration: none;
color: inherit;
}
.small-img-box {
width: 100%;
aspect-ratio: 16 / 10;
border-radius: 8px;
overflow: hidden;
}
.small-img-box img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.small-post:hover .small-img-box img {
transform: scale(1.04);
}
.small-title {
font-size: 14px;
font-weight: 700;
color: #1e293b;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3; /* Giới hạn tối đa 3 dòng */
-webkit-box-orient: vertical;
overflow: hidden;
}
.small-post:hover .small-title {
color: #0284c7;
}
/* ===================================================
5. RESPONSIVE (Cho Tablet & Mobile)
=================================================== */
@media (max-width: 992px) {
.featured-post,
.side-news-grid {
grid-column: span 12; /* Chuyển thành 1 cột dọc lớn */
}
}
@media (max-width: 576px) {
.side-news-grid {
grid-template-columns: 1fr; /* Lưới bài nhỏ chuyển thành 1 cột trên Mobile */
}
.featured-title {
font-size: 18px;
}
} }
</style> </style>
</head> </head>
<body> <body class="p-4 sm:p-6 md:p-8">
<section class="news-section"> <!-- Khung chứa Form: Responsive padding, bo góc và độ rộng -->
<!-- CỘT TRÁI: Bài viết nổi bật lớn --> <div
<article class="featured-post"> class="bg-white rounded-2xl shadow-2xl w-full max-w-xl p-6 sm:p-8 md:p-10 relative my-auto"
<div class="featured-img-box"> >
<img <!-- Tiêu đề -->
src="https://picsum.photos/800/450?random=1" <h2 class="text-xl sm:text-2xl font-bold text-blue-900 mb-6">
alt="Nhà giáo nhân dân Nguyễn Đình Hối" Chia sẻ lời tri ân
/> </h2>
</div>
<div class="meta-row">
<span class="badge">Tin tức</span>
<span class="post-date">06/07/2026</span>
</div>
<a href="#" class="featured-title">Thư cảm tạ</a>
<p class="featured-excerpt">
Đại học Y Dược Thành phố Hồ Chí Minh, Bệnh viện Đại học Y Dược Thành
phố Hồ Chí Minh và gia quyến vô cùng xúc động và trân trọng gửi lời
cảm tạ sâu sắc.
</p>
</article>
<!-- CỘT PHẢI: Lưới 4 bài viết nhỏ (2x2) --> <!-- Form chính -->
<div class="side-news-grid"> <form
<!-- Bài nhỏ 1 --> onsubmit="
<a href="#" class="small-post"> event.preventDefault();
<div class="small-img-box"> alert('Gửi thông tin thành công!');
<img "
src="https://picsum.photos/400/250?random=2" class="space-y-4 sm:space-y-5"
alt="Tuần lễ thế giới nuôi con bằng sữa mẹ" >
<!-- Họ tên -->
<div>
<label class="block text-xs sm:text-sm font-medium text-gray-700 mb-1"
>Họ tên <span class="text-red-500">*</span></label
>
<input
type="text"
required
placeholder="Nhập họ tên"
class="w-full px-3.5 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm placeholder-gray-400"
/> />
</div> </div>
<div class="meta-row">
<span class="badge">Sự kiện</span>
</div>
<h3 class="small-title">
Tuần lễ thế giới nuôi con bằng sữa mẹ 2026
</h3>
<span class="post-date">04/08/2026</span>
</a>
<!-- Bài nhỏ 2 --> <!-- Bạn là & Chức vụ (Responsive Grid: 1 cột trên mobile, 2 cột từ màn hình sm trở lên) -->
<a href="#" class="small-post"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="small-img-box"> <div>
<img <label
src="https://picsum.photos/400/250?random=3" class="block text-xs sm:text-sm font-medium text-gray-700 mb-1"
alt="Đau lưng ở nhân viên văn phòng" >Bạn là <span class="text-red-500">*</span></label
>
<select
required
class="w-full px-3.5 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm bg-white text-gray-500"
>
<option value="" disabled selected>Chọn vai trò</option>
<option value="bac-si">Bác sĩ / Nhân viên y tế</option>
<option value="benh-nhan">Bệnh nhân / Người nhà</option>
<option value="khac">Khác</option>
</select>
</div>
<div>
<label
class="block text-xs sm:text-sm font-medium text-gray-700 mb-1"
>Chức vụ/ Nơi làm việc của bạn
<span class="text-red-500">*</span></label
>
<input
type="text"
required
placeholder="Nhập chức vụ/ nơi làm việc"
class="w-full px-3.5 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm placeholder-gray-400"
/> />
</div> </div>
<div class="meta-row">
<span class="badge">Tin tức</span>
</div> </div>
<h3 class="small-title">
Đau lưng ở nhân viên văn phòng: Đừng đợi đến khi cột sống “lên
tiếng”
</h3>
<span class="post-date">01/08/2026</span>
</a>
<!-- Bài nhỏ 3 --> <!-- Số điện thoại -->
<a href="#" class="small-post"> <div>
<div class="small-img-box"> <label class="block text-xs sm:text-sm font-medium text-gray-700 mb-1"
<img >Số điện thoại <span class="text-red-500">*</span></label
src="https://picsum.photos/400/250?random=4" >
alt="Bệnh lý miễn dịch thần kinh" <input
type="tel"
required
placeholder="Nhập số điện thoại"
class="w-full px-3.5 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm placeholder-gray-400"
/> />
</div> </div>
<div class="meta-row">
<span class="badge">Tin tức</span>
</div>
<h3 class="small-title">
Bệnh lý miễn dịch thần kinh - Phát hiện sớm để điều trị tốt
</h3>
<span class="post-date">31/07/2026</span>
</a>
<!-- Bài nhỏ 4 --> <!-- Nội dung lời nhắn -->
<a href="#" class="small-post"> <div>
<div class="small-img-box"> <label class="block text-xs sm:text-sm font-medium text-gray-700 mb-1"
<img >Nội dung lời nhắn <span class="text-red-500">*</span></label
src="https://picsum.photos/400/250?random=5" >
alt="Phẫu thuật nội soi" <textarea
/> rows="3"
required
placeholder="Nhập nội dung"
class="w-full px-3.5 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm placeholder-gray-400 resize-none"
></textarea>
</div> </div>
<div class="meta-row">
<span class="badge">Tin tức</span> <!-- Tải lên hình ảnh -->
<div>
<label class="block text-xs sm:text-sm font-medium text-gray-700 mb-1"
>Hình ảnh <span class="text-red-500">*</span></label
>
<label
class="flex flex-col items-center justify-center w-28 h-28 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-blue-500 bg-gray-50 transition"
>
<div
class="flex flex-col items-center justify-center pt-5 pb-6 text-gray-400"
>
<svg
class="w-6 h-6 mb-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
></path>
</svg>
<span class="text-xs">Tải tệp</span>
</div> </div>
<h3 class="small-title"> <input type="file" class="hidden" accept="image/*" required />
Phẫu thuật nội soi cắt phì đại liên thất và thay van động mạch chủ </label>
</h3> </div>
<span class="post-date">30/07/2026</span>
</a> <!-- Nút gửi (Full width trên điện thoại, kích thước vừa vặn trên desktop) -->
<div class="pt-2">
<button
type="submit"
class="w-full sm:w-auto px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium text-sm rounded-lg shadow transition duration-200 text-center"
>
Gửi thông tin
</button>
</div>
</form>
</div> </div>
</section>
</body> </body>
</html> </html>
@@ -14,6 +14,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import jakarta.servlet.http.HttpServletResponse;
import com.sisvietnamvn.web.security.AuthoritiesConstants; import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.security.CustomAuthenticationSuccessHandler; import com.sisvietnamvn.web.security.CustomAuthenticationSuccessHandler;
@@ -105,7 +106,40 @@ public class SecurityConfiguration {
.oidcUserService(customOidcUserService) .oidcUserService(customOidcUserService)
) )
) )
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.invalidSessionUrl("/manage/login?expired")
)
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((request, response, authException) -> {
String requestUri = request.getRequestURI();
String acceptHeader = request.getHeader("Accept");
String requestedWith = request.getHeader("X-Requested-With");
boolean isAjax = "XMLHttpRequest".equals(requestedWith) || (acceptHeader != null && acceptHeader.contains("application/json")) || requestUri.startsWith("/api/");
if (isAjax) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"Session expired\",\"message\":\"Forbidden, return to login page again\",\"redirect\":\"/manage/login?expired\"}");
} else {
response.sendRedirect("/manage/login?expired");
}
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
String requestUri = request.getRequestURI();
String acceptHeader = request.getHeader("Accept");
String requestedWith = request.getHeader("X-Requested-With");
boolean isAjax = "XMLHttpRequest".equals(requestedWith) || (acceptHeader != null && acceptHeader.contains("application/json")) || requestUri.startsWith("/api/");
if (isAjax) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"Forbidden\",\"message\":\"Forbidden, return to login page again\",\"redirect\":\"/manage/login?expired\"}");
} else {
response.sendRedirect("/manage/login?expired");
}
})
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())); .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
if (env.acceptsProfiles(Profiles.of(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT))) { if (env.acceptsProfiles(Profiles.of(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT))) {
http.authorizeHttpRequests(authz -> authz.requestMatchers("/h2-console/**").permitAll()) http.authorizeHttpRequests(authz -> authz.requestMatchers("/h2-console/**").permitAll())
@@ -467,9 +467,16 @@ public class PageController {
} }
} }
String rawSlug = page.getSlug();
String slugClass = (rawSlug != null && !rawSlug.trim().isEmpty())
? rawSlug.trim().toLowerCase().replaceAll("[^a-z0-9_-]", "-")
: "page-default";
model.addAttribute("page", page); model.addAttribute("page", page);
model.addAttribute("blocks", blocks); model.addAttribute("blocks", blocks);
model.addAttribute("hookManager", hookManager); model.addAttribute("hookManager", hookManager);
model.addAttribute("pageSlugClass", slugClass);
model.addAttribute("bodyClass", "page-" + slugClass + " " + slugClass);
// If the page contains a 'posts' block, load published posts so the template can render them // 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"))); boolean hasPostsBlock = blocks.stream().anyMatch(b -> "posts".equals(b.get("type")));
@@ -0,0 +1,128 @@
package com.sisvietnamvn.web.plugins.googleForm;
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/google-form")
public class googleFormAdminController {
private final SettingService settingService;
private final AdminContext adminContext;
private final ObjectMapper objectMapper;
private static final String SETTING_KEY = googleFormPlugin.SETTING_KEY;
public googleFormAdminController(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<GoogleFormItem> forms = parseFormsJson(json);
model.addAttribute("forms", forms);
model.addAttribute("formsJson", json);
model.addAttribute("pageTitle", "Quản lý Google Form & Form Tương Tác");
return "plugins/google-form/admin-settings";
}
@PostMapping
public String save(@RequestParam(value = "formData", required = false) String formDataJson, RedirectAttributes redirectAttributes) {
if (!adminContext.currentUserCan("manage_options")) {
return "error/403";
}
if (formDataJson != null && !formDataJson.trim().isEmpty()) {
try {
objectMapper.readValue(formDataJson, new TypeReference<List<GoogleFormItem>>() {});
settingService.setValue(SETTING_KEY, formDataJson);
redirectAttributes.addFlashAttribute("successMessage", "Đã lưu cấu hình Google Form 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ộ cấu hình Google Form!");
}
return "redirect:/manage/plugins/google-form";
}
public List<GoogleFormItem> parseFormsJson(String json) {
if (json == null || json.trim().isEmpty() || "[]".equals(json)) {
return new ArrayList<>();
}
try {
return objectMapper.readValue(json, new TypeReference<List<GoogleFormItem>>() {});
} catch (Exception e) {
return new ArrayList<>();
}
}
public static class GoogleFormItem {
private String id;
private String name;
private String title;
private String subtitle;
private String formType; // 'google_iframe', 'tri_an', 'contact'
private String embedUrl;
private String height;
private String theme; // 'blue_gradient', 'red_gradient', 'teal_gradient', 'light'
private String buttonText;
private String successMsg;
public GoogleFormItem() {}
public GoogleFormItem(String id, String name, String title, String subtitle, String formType, String embedUrl, String height, String theme, String buttonText, String successMsg) {
this.id = id;
this.name = name;
this.title = title;
this.subtitle = subtitle;
this.formType = formType;
this.embedUrl = embedUrl;
this.height = height;
this.theme = theme;
this.buttonText = buttonText;
this.successMsg = successMsg;
}
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 getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSubtitle() { return subtitle; }
public void setSubtitle(String subtitle) { this.subtitle = subtitle; }
public String getFormType() { return formType; }
public void setFormType(String formType) { this.formType = formType; }
public String getEmbedUrl() { return embedUrl; }
public void setEmbedUrl(String embedUrl) { this.embedUrl = embedUrl; }
public String getHeight() { return height; }
public void setHeight(String height) { this.height = height; }
public String getTheme() { return theme; }
public void setTheme(String theme) { this.theme = theme; }
public String getButtonText() { return buttonText; }
public void setButtonText(String buttonText) { this.buttonText = buttonText; }
public String getSuccessMsg() { return successMsg; }
public void setSuccessMsg(String successMsg) { this.successMsg = successMsg; }
}
}
@@ -0,0 +1,329 @@
package com.sisvietnamvn.web.plugins.googleForm;
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 googleFormPlugin {
private static final Logger LOG = LoggerFactory.getLogger(googleFormPlugin.class);
private static final String PLUGIN_KEY = "google-form-plugin";
public static final String SETTING_KEY = "plugin_google_form_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 googleFormPlugin(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("Google Form & Form Tương Tác Tri Ân");
newPlugin.setVersion("1.0");
newPlugin.setStatus(PluginStatus.ACTIVE);
newPlugin.setAuthor("Antigravity");
newPlugin.setDescription("Plugin Google Form nhúng iframe và Form tương tác tri ân với hỗ trợ shortcode [plugin:google-form id=\"...\"]");
return pluginRepository.save(newPlugin);
});
isActive = p.getStatus() == PluginStatus.ACTIVE;
} catch (Exception e) {
LOG.warn("Could not verify googleFormPlugin status: {}", e.getMessage());
return;
}
if (!isActive) {
LOG.info("googleFormPlugin is inactive.");
return;
}
LOG.info("googleFormPlugin 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/google-form\">Google Form & Tri Ân</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:google-form")) {
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:google-form] or [plugin:google-form id="xyz"] or [plugin:google-form url="https://..."]
Pattern pattern = Pattern.compile("\\[plugin:google-form(?:\\s+(?:id|slug)=\"([^\"]+)\")?(?:\\s+url=\"([^\"]+)\")?\\s*\\]");
Matcher matcher = pattern.matcher(text);
StringBuilder sb = new StringBuilder();
while (matcher.find()) {
String targetId = matcher.group(1);
String directUrl = matcher.group(2);
String replacement = generateGoogleFormHtml(targetId, directUrl);
matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
}
matcher.appendTail(sb);
return sb.toString();
}
private String generateGoogleFormHtml(String targetId, String directUrl) {
// If direct Google Form URL is provided in shortcode
if (directUrl != null && !directUrl.trim().isEmpty()) {
return buildIframeHtml(directUrl.trim(), "650px", "blue_gradient");
}
String json = settingService.getValue(SETTING_KEY, "[]");
List<googleFormAdminController.GoogleFormItem> forms = new ArrayList<>();
try {
forms = objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
LOG.error("Failed to parse GoogleForm JSON: {}", e.getMessage());
}
if (forms.isEmpty()) {
googleFormAdminController.GoogleFormItem defaultForm = new googleFormAdminController.GoogleFormItem(
"form-1",
"Form Tri Ân S.I.S Cần Thơ",
"Chia sẻ lời tri ân",
"Gửi lời nhắn cảm ơn chân thành đến đội ngũ bác sĩ và nhân viên y tế",
"tri_an",
"",
"650px",
"red_gradient",
"Gửi thông tin",
"Gửi thông tin tri ân thành công!"
);
forms.add(defaultForm);
}
// Find requested form or default to first form
googleFormAdminController.GoogleFormItem selectedForm = forms.get(0);
if (targetId != null && !targetId.trim().isEmpty()) {
for (googleFormAdminController.GoogleFormItem f : forms) {
if (f.getId() != null && targetId.trim().equalsIgnoreCase(f.getId())) {
selectedForm = f;
break;
}
}
}
String fType = selectedForm.getFormType() != null ? selectedForm.getFormType() : "tri_an";
String theme = selectedForm.getTheme() != null ? selectedForm.getTheme() : "blue_gradient";
if ("google_iframe".equalsIgnoreCase(fType)) {
String embedUrl = selectedForm.getEmbedUrl() != null ? selectedForm.getEmbedUrl().trim() : "";
String height = selectedForm.getHeight() != null && !selectedForm.getHeight().trim().isEmpty() ? selectedForm.getHeight().trim() : "650px";
return buildIframeHtml(embedUrl, height, theme);
} else if ("contact".equalsIgnoreCase(fType)) {
return buildContactFormHtml(selectedForm);
} else {
// Default: 'tri_an' layout (inspired by temp.html)
return buildTriAnFormHtml(selectedForm);
}
}
private String buildIframeHtml(String embedUrl, String height, String theme) {
if (embedUrl == null || embedUrl.isEmpty()) {
return "<div class='p-4 text-center text-muted bg-light rounded my-3'><p class='m-0 font-weight-bold'>Vui lòng nhập Google Form Embed URL.</p></div>";
}
String bgStyle = getThemeBackground(theme);
StringBuilder sb = new StringBuilder();
sb.append("<div class='sis-google-form-embed-wrapper p-3 p-sm-4 text-center my-4' style='border-radius:16px; background:").append(bgStyle).append(";'>");
sb.append(" <iframe src='").append(escapeHtml(embedUrl)).append("' style='width:100%; border:none; border-radius:12px; background:#ffffff; box-shadow:0 10px 25px rgba(0,0,0,0.15); height:").append(escapeHtml(height)).append(";'></iframe>");
sb.append("</div>");
return sb.toString();
}
private String buildTriAnFormHtml(googleFormAdminController.GoogleFormItem formItem) {
String title = formItem.getTitle() != null && !formItem.getTitle().isEmpty() ? formItem.getTitle() : "Chia sẻ lời tri ân";
String subtitle = formItem.getSubtitle() != null ? formItem.getSubtitle() : "";
String btnText = formItem.getButtonText() != null && !formItem.getButtonText().isEmpty() ? formItem.getButtonText() : "Gửi thông tin";
String successMsg = formItem.getSuccessMsg() != null && !formItem.getSuccessMsg().isEmpty() ? formItem.getSuccessMsg() : "Gửi thông tin thành công!";
String theme = formItem.getTheme() != null ? formItem.getTheme() : "blue_gradient";
String headerColor = getThemeHeaderColor(theme);
String btnStyle = getThemeBtnStyle(theme);
StringBuilder sb = new StringBuilder();
sb.append("<div class='sis-tri-an-container p-3 p-sm-4 p-md-5 d-flex align-items-center justify-content-center my-4' style='min-height:100%; border-radius:20px; background:").append(getThemeBackground(theme)).append(";'>");
sb.append(" <div class='bg-white rounded-2xl shadow-2xl w-100 p-4 p-sm-4 p-md-5 my-auto' style='max-width:580px; border-radius:16px; box-shadow:0 20px 40px rgba(0,0,0,0.15); font-family:system-ui,-apple-system,sans-serif;'>");
sb.append(" <h2 class='h4 font-weight-bold mb-3' style='color:").append(headerColor).append(";'>").append(escapeHtml(title)).append("</h2>");
if (!subtitle.isEmpty()) {
sb.append(" <p class='text-muted small mb-4'>").append(escapeHtml(subtitle)).append("</p>");
}
sb.append(" <form class='sis-tri-an-form' data-success-msg='").append(escapeHtml(successMsg)).append("' onsubmit='event.preventDefault(); alert(this.getAttribute(\"data-success-msg\")); this.reset();'>");
sb.append(" <div class='mb-3'>");
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Họ tên <span class='text-danger'>*</span></label>");
sb.append(" <input type='text' name='fullName' required placeholder='Nhập họ tên' class='form-control form-control-lg border-gray-300 rounded-lg text-sm'/>");
sb.append(" </div>");
sb.append(" <div class='row g-3 mb-3'>");
sb.append(" <div class='col-12 col-sm-6'>");
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Bạn là <span class='text-danger'>*</span></label>");
sb.append(" <select name='role' required class='form-select form-control form-control-lg border-gray-300 rounded-lg text-sm bg-white text-gray-500'>");
sb.append(" <option value='' disabled selected>Chọn vai trò</option>");
sb.append(" <option value='bac-si'>Bác sĩ / Nhân viên y tế</option>");
sb.append(" <option value='benh-nhan'>Bệnh nhân / Người nhà</option>");
sb.append(" <option value='khac'>Khác</option>");
sb.append(" </select>");
sb.append(" </div>");
sb.append(" <div class='col-12 col-sm-6'>");
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Chức vụ/ Nơi làm việc <span class='text-danger'>*</span></label>");
sb.append(" <input type='text' name='workplace' required placeholder='Nhập chức vụ/ nơi làm việc' class='form-control form-control-lg border-gray-300 rounded-lg text-sm'/>");
sb.append(" </div>");
sb.append(" </div>");
sb.append(" <div class='mb-3'>");
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Số điện thoại <span class='text-danger'>*</span></label>");
sb.append(" <input type='tel' name='phone' required placeholder='Nhập số điện thoại' class='form-control form-control-lg border-gray-300 rounded-lg text-sm'/>");
sb.append(" </div>");
sb.append(" <div class='mb-3'>");
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Nội dung lời nhắn <span class='text-danger'>*</span></label>");
sb.append(" <textarea name='message' rows='3' required placeholder='Nhập nội dung' class='form-control border-gray-300 rounded-lg text-sm' style='resize:none;'></textarea>");
sb.append(" </div>");
sb.append(" <div class='mb-4'>");
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Hình ảnh <span class='text-danger'>*</span></label>");
sb.append(" <label class='d-flex flex-column align-items-center justify-content-center border border-2 border-dashed border-gray-300 rounded-lg cursor-pointer bg-light p-3 text-muted hover-border-primary' style='width: 110px; height: 110px; cursor: pointer;'>");
sb.append(" <svg width='24' height='24' fill='none' stroke='currentColor' viewBox='0 0 24 24' class='mb-1'><path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M12 4v16m8-8H4'></path></svg>");
sb.append(" <span class='small' style='font-size: 11px;'>Tải tệp</span>");
sb.append(" <input type='file' name='image' accept='image/*' class='d-none' required onchange='if(this.files && this.files[0]){ this.previousElementSibling.textContent = this.files[0].name.length > 12 ? this.files[0].name.substring(0,9)+\"...\" : this.files[0].name; }'/>");
sb.append(" </label>");
sb.append(" </div>");
sb.append(" <div class='pt-2'>");
sb.append(" <button type='submit' class='btn text-white font-weight-bold px-4 py-2 rounded-lg shadow-sm w-100 w-sm-auto' style='").append(btnStyle).append("'>").append(escapeHtml(btnText)).append("</button>");
sb.append(" </div>");
sb.append(" </form>");
sb.append(" </div>");
sb.append("</div>");
return sb.toString();
}
private String buildContactFormHtml(googleFormAdminController.GoogleFormItem formItem) {
String title = formItem.getTitle() != null && !formItem.getTitle().isEmpty() ? formItem.getTitle() : "Form Liên hệ & Góp ý";
String subtitle = formItem.getSubtitle() != null ? formItem.getSubtitle() : "";
String btnText = formItem.getButtonText() != null && !formItem.getButtonText().isEmpty() ? formItem.getButtonText() : "Gửi thông tin";
String successMsg = formItem.getSuccessMsg() != null && !formItem.getSuccessMsg().isEmpty() ? formItem.getSuccessMsg() : "Gửi thông tin thành công!";
String theme = formItem.getTheme() != null ? formItem.getTheme() : "blue_gradient";
String headerColor = getThemeHeaderColor(theme);
String btnStyle = getThemeBtnStyle(theme);
StringBuilder sb = new StringBuilder();
sb.append("<div class='sis-contact-container p-3 p-sm-4 p-md-5 d-flex align-items-center justify-content-center my-4' style='min-height:100%; border-radius:20px; background:").append(getThemeBackground(theme)).append(";'>");
sb.append(" <div class='bg-white rounded-2xl shadow-2xl w-100 p-4 p-sm-4 p-md-5 my-auto' style='max-width:580px; border-radius:16px; box-shadow:0 20px 40px rgba(0,0,0,0.15); font-family:system-ui,-apple-system,sans-serif;'>");
sb.append(" <h2 class='h4 font-weight-bold mb-3' style='color:").append(headerColor).append(";'>").append(escapeHtml(title)).append("</h2>");
if (!subtitle.isEmpty()) {
sb.append(" <p class='text-muted small mb-4'>").append(escapeHtml(subtitle)).append("</p>");
}
sb.append(" <form class='sis-contact-form' data-success-msg='").append(escapeHtml(successMsg)).append("' onsubmit='event.preventDefault(); alert(this.getAttribute(\"data-success-msg\")); this.reset();'>");
sb.append(" <div class='mb-3'>");
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Họ và tên <span class='text-danger'>*</span></label>");
sb.append(" <input type='text' name='fullName' required placeholder='Nhập họ và tên' class='form-control form-control-lg border-gray-300 rounded-lg text-sm'/>");
sb.append(" </div>");
sb.append(" <div class='row g-3 mb-3'>");
sb.append(" <div class='col-12 col-sm-6'>");
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Email <span class='text-danger'>*</span></label>");
sb.append(" <input type='email' name='email' required placeholder='email@example.com' class='form-control form-control-lg border-gray-300 rounded-lg text-sm'/>");
sb.append(" </div>");
sb.append(" <div class='col-12 col-sm-6'>");
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Số điện thoại</label>");
sb.append(" <input type='tel' name='phone' placeholder='Nhập số điện thoại' class='form-control form-control-lg border-gray-300 rounded-lg text-sm'/>");
sb.append(" </div>");
sb.append(" </div>");
sb.append(" <div class='mb-3'>");
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Nội dung tin nhắn <span class='text-danger'>*</span></label>");
sb.append(" <textarea name='message' rows='4' required placeholder='Nhập nội dung cần hỗ trợ...' class='form-control border-gray-300 rounded-lg text-sm' style='resize:none;'></textarea>");
sb.append(" </div>");
sb.append(" <div class='pt-2'>");
sb.append(" <button type='submit' class='btn text-white font-weight-bold px-4 py-2 rounded-lg shadow-sm w-100 w-sm-auto' style='").append(btnStyle).append("'>").append(escapeHtml(btnText)).append("</button>");
sb.append(" </div>");
sb.append(" </form>");
sb.append(" </div>");
sb.append("</div>");
return sb.toString();
}
private String getThemeBackground(String theme) {
if ("red_gradient".equalsIgnoreCase(theme)) {
return "linear-gradient(135deg, #701a1a 0%, #d7193f 50%, #fff0f3 100%)";
} else if ("teal_gradient".equalsIgnoreCase(theme)) {
return "linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #f0fdfa 100%)";
} else if ("light".equalsIgnoreCase(theme)) {
return "#f1f5f9";
} else {
return "linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #eff6ff 100%)";
}
}
private String getThemeHeaderColor(String theme) {
if ("red_gradient".equalsIgnoreCase(theme)) return "#881C1C";
if ("teal_gradient".equalsIgnoreCase(theme)) return "#0f766e";
if ("light".equalsIgnoreCase(theme)) return "#1e293b";
return "#1e3a8a";
}
private String getThemeBtnStyle(String theme) {
if ("red_gradient".equalsIgnoreCase(theme)) return "background:#d7193f; color:#fff;";
if ("teal_gradient".equalsIgnoreCase(theme)) return "background:#0d9488; color:#fff;";
if ("light".equalsIgnoreCase(theme)) return "background:#1e293b; color:#fff;";
return "background:#2563eb; color:#fff;";
}
private String escapeHtml(String text) {
if (text == null) return "";
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
}
@@ -493,9 +493,7 @@ figure.table table tr:hover {
.flex { .flex {
display: flex !important; display: flex !important;
} }
.education .flex .h-stretch {
height: stretch;
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@@ -614,6 +612,54 @@ figure.table table tr:hover {
background-color: #f0aeae; background-color: #f0aeae;
} }
.news-section-v3 .featured-post-wrapper {
grid-column: span 6;
}
.doctor-page .card-icon-shadow {
display: none;
}
.tri-an .container {
background-image: url('https://theme.hstatic.net/200000736895/1001385148/14/feedback_3_image.png?v=302');
background-size: cover;
}
.page-cong-tac-xa-hoi {
.sis-hero-banner {
.container {
text-align: -webkit-right;
h1 {
font-size: 4rem;
width: 50%;
}
p {
width: 50%;
}
}
}
}
.h-10 {
height: calc(var(--spacing) * 10);
}
.w-10 {
width: calc(var(--spacing) * 10);
}
.justify-center {
justify-content: center;
}
.items-center {
align-items: center;
}
.h-stretch {
height: stretch;
}
/* ========================================================================== /* ==========================================================================
5. Responsive Breakpoints — 3 Groups Only 5. Responsive Breakpoints — 3 Groups Only
========================================================================== */ ========================================================================== */
@@ -816,6 +862,21 @@ figure.table table tr:hover {
#wrapperCrowdDoctors { #wrapperCrowdDoctors {
display: none; display: none;
} }
.page-cong-tac-xa-hoi {
.sis-hero-banner {
.container {
text-align: -webkit-right;
h1 {
font-size: 2rem;
width: unset;
}
p {
width: unset;
}
}
}
}
} }
/* --- Responsive Tab Plugin (Switch to Select / Dropdown on Mobile & Tablet <= 1024px) --- */ /* --- Responsive Tab Plugin (Switch to Select / Dropdown on Mobile & Tablet <= 1024px) --- */
@@ -2167,3 +2228,185 @@ img[width='100px'] {
.sis-recruiting-detail-btn { .sis-recruiting-detail-btn {
white-space: nowrap !important; white-space: nowrap !important;
} }
/* --- Global Posts Block Pagination & Grid Styles --- */
.sis-posts-block .pagination-container,
.sis-posts-block ul.pagination,
ul.pagination-container {
display: flex !important;
justify-content: center !important;
align-items: center !important;
list-style: none !important;
list-style-type: none !important;
gap: 8px !important;
margin-top: 28px !important;
margin-bottom: 0 !important;
padding-left: 0 !important;
padding-right: 0 !important;
width: 100% !important;
flex-direction: row !important;
}
.sis-posts-block .pagination-container li,
.sis-posts-block .pagination-container .page-item,
.sis-posts-block ul.pagination li,
ul.pagination-container li {
list-style: none !important;
list-style-type: none !important;
margin: 0 !important;
padding: 0 !important;
display: inline-flex !important;
}
.sis-posts-block .pagination-container .page-link,
.sis-posts-block ul.pagination .page-link,
ul.pagination-container .page-link {
display: inline-flex !important;
justify-content: center !important;
align-items: center !important;
min-width: 38px !important;
height: 38px !important;
padding: 0 12px !important;
border-radius: 8px !important;
border: 1px solid #cbd5e1 !important;
background-color: #ffffff !important;
color: #475569 !important;
text-decoration: none !important;
font-size: 14px !important;
font-weight: 600 !important;
transition: all 0.2s ease !important;
cursor: pointer !important;
user-select: none !important;
line-height: 1 !important;
}
.sis-posts-block .pagination-container .page-link:hover,
.sis-posts-block ul.pagination .page-link:hover,
ul.pagination-container .page-link:hover {
border-color: #0284c7 !important;
color: #0284c7 !important;
background-color: #f0f9ff !important;
text-decoration: none !important;
}
.sis-posts-block .pagination-container .page-item.active .page-link,
.sis-posts-block .pagination-container .page-link.active,
.sis-posts-block ul.pagination li.active .page-link,
ul.pagination-container li.active .page-link {
background-color: #0284c7 !important;
color: #ffffff !important;
border-color: #0284c7 !important;
box-shadow: 0 2px 6px rgba(2, 132, 199, 0.3) !important;
}
.sis-posts-block .pagination-container .page-item.disabled .page-link,
.sis-posts-block ul.pagination li.disabled .page-link,
ul.pagination-container li.disabled .page-link {
opacity: 0.45 !important;
pointer-events: none !important;
cursor: not-allowed !important;
}
/* Practice Cards Block Component (Untitled-5.html) */
.sis-practice-card-section {
padding: 1rem 0;
}
.practice-card {
background-color: var(--card-bg, #ffffff);
border-radius: var(--card-radius, 16px);
border: none;
box-shadow: var(--card-shadow, 0 4px 20px rgba(0, 0, 0, 0.05));
transition: all 0.3s ease;
height: 100%;
padding: 1.75rem 1.5rem 1.25rem 1.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.practice-card:hover {
transform: translateY(-4px);
box-shadow: var(--card-shadow-hover, 0 8px 30px rgba(0, 0, 0, 0.12));
}
.card-title-custom {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-dark, #1e293b);
line-height: 1.4;
margin-bottom: 2rem;
}
.card-footer-custom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
padding-top: 1rem;
}
.date-text {
font-size: 0.875rem;
color: var(--text-muted, #64748b);
font-weight: 500;
}
.action-link {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--text-dark, #1e293b);
font-weight: 600;
font-size: 0.95rem;
transition: color 0.2s ease;
}
.action-link:hover {
color: var(--primary-blue, #0066cc);
text-decoration: none;
}
.icon-circle {
width: 32px;
height: 32px;
background-color: var(--primary-blue, #0066cc);
color: #ffffff;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
transition: transform 0.2s ease, background-color 0.2s ease;
position: relative;
flex-shrink: 0;
}
.icon-circle i {
display: inline-flex;
align-items: center;
justify-content: center;
color: #ffffff !important;
font-size: 0.85rem !important;
}
/* Fallback chevron arrow if font-awesome / bootstrap-icons fails to load */
.icon-circle i:empty::before,
.icon-circle:empty::before {
content: '';
display: inline-block;
width: 7px;
height: 7px;
border-right: 2.2px solid #ffffff;
border-top: 2.2px solid #ffffff;
transform: rotate(45deg);
margin-left: -2px;
}
.action-link:hover .icon-circle {
background-color: #0052a3;
transform: translateX(3px);
}
@@ -433,6 +433,12 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
if (typeof SISHeroBannerTool !== 'undefined') { if (typeof SISHeroBannerTool !== 'undefined') {
builtInTools.hero = { class: SISHeroBannerTool }; builtInTools.hero = { class: SISHeroBannerTool };
} }
if (typeof SISGoogleFormTool !== 'undefined') {
builtInTools.googleForm = { class: SISGoogleFormTool };
}
if (typeof CMSPluginTool !== 'undefined') {
builtInTools.cmsPlugin = { class: CMSPluginTool };
}
if (typeof SISStickyNavTool !== 'undefined') { if (typeof SISStickyNavTool !== 'undefined') {
builtInTools.stickyNav = { class: SISStickyNavTool }; builtInTools.stickyNav = { class: SISStickyNavTool };
} }
@@ -0,0 +1,350 @@
/**
* SISCardsTool — Dedicated Practice Cards / Content Cards Block Tool for Editor.js
* Creates responsive practice cards matching the design from Untitled-5.html.
*/
class SISCardsTool {
static get toolbox() {
return {
title: 'Cards / Practice Cards',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="2"></rect><line x1="7" y1="8" x2="17" y2="8"></line><line x1="7" y1="12" x2="13" y2="12"></line><circle cx="16" cy="16" r="1.5"></circle></svg>'
};
}
constructor({ data, api, readOnly, block }) {
this.api = api;
this.blockAPI = block;
this.readOnly = readOnly;
this.instanceId = 'cards_' + Math.random().toString(36).substring(7);
this.data = {
cols: parseInt(data && data.cols) || 3,
globalClass: (data && data.globalClass) || 'sis-practice-card-section',
globalId: (data && data.globalId) || '',
globalAttributes: (data && data.globalAttributes) || (data && data.attributes) || '',
items: (data && data.items && Array.isArray(data.items)) ? data.items : []
};
// Add initial default item if list is empty
if (this.data.items.length === 0) {
this.data.items.push({
title: 'Hộ sinh: Danh sách người thực hành',
date: '22/06/2026',
linkUrl: '#',
linkText: 'Tìm hiểu thêm',
linkTarget: '_self',
active: true
});
}
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-cards-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
const titleLabel = document.createElement('div');
titleLabel.className = 'font-weight-bold text-primary small mb-3';
titleLabel.innerHTML = '<i class="fas fa-id-card"></i> Practice Cards Settings';
this.wrapper.appendChild(titleLabel);
// Global Settings Row
const settingsRow = document.createElement('div');
settingsRow.className = 'form-row mb-3 pb-2 border-bottom';
// 1. Cols selector
const colDiv = document.createElement('div');
colDiv.className = 'col-md-3 mb-2';
colDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Columns per Row</label>';
const colSelect = document.createElement('select');
colSelect.className = 'form-control form-control-sm';
[1, 2, 3, 4, 6].forEach(num => {
const opt = document.createElement('option');
opt.value = num;
opt.textContent = `${num} Column${num > 1 ? 's' : ''}`;
if (num === this.data.cols) opt.selected = true;
colSelect.appendChild(opt);
});
if (this.readOnly) colSelect.disabled = true;
colSelect.addEventListener('change', (e) => {
this.data.cols = parseInt(e.target.value) || 3;
});
this.colsSelect = colSelect;
colDiv.appendChild(colSelect);
settingsRow.appendChild(colDiv);
// 2. Global 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">Custom CSS Class</label>';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.placeholder = 'e.g. sis-practice-card-section';
classInput.value = this.data.globalClass;
if (this.readOnly) classInput.disabled = true;
this.globalClassInput = classInput;
classDiv.appendChild(classInput);
settingsRow.appendChild(classDiv);
// 3. Global 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">Custom Element ID</label>';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.placeholder = 'e.g. practice-cards-list';
idInput.value = this.data.globalId;
if (this.readOnly) idInput.disabled = true;
this.globalIdInput = idInput;
idDiv.appendChild(idInput);
settingsRow.appendChild(idDiv);
// 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 = 'data-aos="fade-up"';
attrInput.value = this.data.globalAttributes;
if (this.readOnly) attrInput.disabled = true;
this.globalAttributesInput = attrInput;
attrDiv.appendChild(attrInput);
settingsRow.appendChild(attrDiv);
this.wrapper.appendChild(settingsRow);
// Items Container
this.itemsContainer = document.createElement('div');
this.itemsContainer.className = 'cards-items-container';
this.wrapper.appendChild(this.itemsContainer);
this.renderItems();
// Add Card Button
if (!this.readOnly) {
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'btn btn-sm btn-outline-primary mt-2 font-weight-bold';
addBtn.innerHTML = '<i class="fas fa-plus mr-1"></i> Add Card Item';
addBtn.addEventListener('click', () => {
this.data.items.push({
title: 'Bác sĩ y khoa: Danh sách người thực hành',
date: new Date().toLocaleDateString('vi-VN'),
linkUrl: '#',
linkText: 'Tìm hiểu thêm',
linkTarget: '_self',
active: true
});
this.renderItems();
});
this.wrapper.appendChild(addBtn);
}
return this.wrapper;
}
renderItems() {
this.itemsContainer.innerHTML = '';
this.data.items.forEach((item, index) => {
const itemBox = document.createElement('div');
itemBox.className = 'card mb-2 shadow-sm border-secondary';
itemBox.style.borderRadius = '8px';
const cardHeader = document.createElement('div');
cardHeader.className = 'card-header bg-white py-2 px-3 d-flex align-items-center justify-content-between cursor-pointer';
cardHeader.innerHTML = `
<span class="font-weight-bold text-dark small">
<i class="fas fa-id-card text-primary mr-1"></i> Card #${index + 1}: ${this.escapeHtml(item.title || 'Untitled Card')}
</span>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-link text-secondary p-0 mr-2 toggle-btn" title="Toggle Edit"><i class="fas fa-chevron-down"></i></button>
${index > 0 ? `<button type="button" class="btn btn-link text-info p-0 mr-2 move-up-btn" title="Move Up"><i class="fas fa-arrow-up"></i></button>` : ''}
${index < this.data.items.length - 1 ? `<button type="button" class="btn btn-link text-info p-0 mr-2 move-down-btn" title="Move Down"><i class="fas fa-arrow-down"></i></button>` : ''}
${this.data.items.length > 1 ? `<button type="button" class="btn btn-link text-danger p-0 delete-btn" title="Delete Card"><i class="fas fa-trash"></i></button>` : ''}
</div>
`;
const cardBody = document.createElement('div');
cardBody.className = 'card-body p-3';
// Inputs
const row1 = document.createElement('div');
row1.className = 'form-row mb-2';
// Title
const titleCol = document.createElement('div');
titleCol.className = 'col-md-8 mb-2';
titleCol.innerHTML = '<label class="small font-weight-bold mb-1">Card Title</label>';
const titleInp = document.createElement('input');
titleInp.type = 'text';
titleInp.className = 'form-control form-control-sm';
titleInp.value = item.title || '';
titleInp.placeholder = 'e.g. Hộ sinh: Danh sách người thực hành';
if (this.readOnly) titleInp.disabled = true;
titleInp.addEventListener('input', (e) => {
item.title = e.target.value;
cardHeader.querySelector('.font-weight-bold').innerHTML = `<i class="fas fa-id-card text-primary mr-1"></i> Card #${index + 1}: ${this.escapeHtml(item.title || 'Untitled Card')}`;
});
titleCol.appendChild(titleInp);
row1.appendChild(titleCol);
// Date
const dateCol = document.createElement('div');
dateCol.className = 'col-md-4 mb-2';
dateCol.innerHTML = '<label class="small font-weight-bold mb-1">Date</label>';
const dateInp = document.createElement('input');
dateInp.type = 'text';
dateInp.className = 'form-control form-control-sm';
dateInp.value = item.date || '';
dateInp.placeholder = 'e.g. 22/06/2026';
if (this.readOnly) dateInp.disabled = true;
dateInp.addEventListener('input', (e) => {
item.date = e.target.value;
});
dateCol.appendChild(dateInp);
row1.appendChild(dateCol);
const row2 = document.createElement('div');
row2.className = 'form-row mb-2';
// Link URL
const urlCol = document.createElement('div');
urlCol.className = 'col-md-6 mb-2';
urlCol.innerHTML = '<label class="small font-weight-bold mb-1">Link URL</label>';
const urlInp = document.createElement('input');
urlInp.type = 'text';
urlInp.className = 'form-control form-control-sm';
urlInp.value = item.linkUrl || '';
urlInp.placeholder = 'e.g. /documents/list.pdf or #';
if (this.readOnly) urlInp.disabled = true;
urlInp.addEventListener('input', (e) => {
item.linkUrl = e.target.value;
});
urlCol.appendChild(urlInp);
row2.appendChild(urlCol);
// Link Text
const textCol = document.createElement('div');
textCol.className = 'col-md-4 mb-2';
textCol.innerHTML = '<label class="small font-weight-bold mb-1">Link Text</label>';
const textInp = document.createElement('input');
textInp.type = 'text';
textInp.className = 'form-control form-control-sm';
textInp.value = item.linkText || 'Tìm hiểu thêm';
textInp.placeholder = 'e.g. Tìm hiểu thêm';
if (this.readOnly) textInp.disabled = true;
textInp.addEventListener('input', (e) => {
item.linkText = e.target.value;
});
textCol.appendChild(textInp);
row2.appendChild(textCol);
// Link Target
const targetCol = document.createElement('div');
targetCol.className = 'col-md-2 mb-2';
targetCol.innerHTML = '<label class="small font-weight-bold mb-1">Target</label>';
const targetSelect = document.createElement('select');
targetSelect.className = 'form-control form-control-sm';
const optSelf = document.createElement('option');
optSelf.value = '_self';
optSelf.textContent = 'Same Tab (_self)';
if ((item.linkTarget || '_self') === '_self') optSelf.selected = true;
targetSelect.appendChild(optSelf);
const optBlank = document.createElement('option');
optBlank.value = '_blank';
optBlank.textContent = 'New Tab (_blank)';
if (item.linkTarget === '_blank') optBlank.selected = true;
targetSelect.appendChild(optBlank);
if (this.readOnly) targetSelect.disabled = true;
targetSelect.addEventListener('change', (e) => {
item.linkTarget = e.target.value;
});
targetCol.appendChild(targetSelect);
row2.appendChild(targetCol);
cardBody.appendChild(row1);
cardBody.appendChild(row2);
itemBox.appendChild(cardHeader);
itemBox.appendChild(cardBody);
this.itemsContainer.appendChild(itemBox);
// Event Listeners for action buttons
const toggleBtn = cardHeader.querySelector('.toggle-btn');
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isHidden = cardBody.style.display === 'none';
cardBody.style.display = isHidden ? 'block' : 'none';
toggleBtn.querySelector('i').className = isHidden ? 'fas fa-chevron-down' : 'fas fa-chevron-right';
});
const moveUpBtn = cardHeader.querySelector('.move-up-btn');
if (moveUpBtn) {
moveUpBtn.addEventListener('click', (e) => {
e.stopPropagation();
const temp = this.data.items[index];
this.data.items[index] = this.data.items[index - 1];
this.data.items[index - 1] = temp;
this.renderItems();
});
}
const moveDownBtn = cardHeader.querySelector('.move-down-btn');
if (moveDownBtn) {
moveDownBtn.addEventListener('click', (e) => {
e.stopPropagation();
const temp = this.data.items[index];
this.data.items[index] = this.data.items[index + 1];
this.data.items[index + 1] = temp;
this.renderItems();
});
}
const deleteBtn = cardHeader.querySelector('.delete-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (confirm('Are you sure you want to remove this card?')) {
this.data.items.splice(index, 1);
this.renderItems();
}
});
}
});
}
escapeHtml(text) {
if (!text) return '';
return text.replace(/[&<>"']/g, function(m) {
return {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}[m];
});
}
save() {
return {
cols: this.colsSelect ? parseInt(this.colsSelect.value) || 3 : this.data.cols,
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || 'sis-practice-card-section'),
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
globalAttributes: this.globalAttributesInput ? this.globalAttributesInput.value.trim() : (this.data.globalAttributes || ''),
items: this.data.items
};
}
}
// Register Plugin
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['cards'] = { class: SISCardsTool, inlineToolbar: true };
window.SISEditorPlugins['practiceCards'] = { class: SISCardsTool, inlineToolbar: true };
window.SISEditorPlugins['practice-cards'] = { class: SISCardsTool, inlineToolbar: true };
@@ -1,6 +1,7 @@
/** /**
* CMS Plugin Shortcode Tool for Editor.js (SIS Vietnam) * CMS Plugin Shortcode Tool for Editor.js (SIS Vietnam)
* Enables inserting plugin shortcodes like [plugin:price-table] or [plugin:price-table id="table_1"] * Enables inserting plugin shortcodes like [plugin:price-table] or [plugin:price-table id="table_1"]
* Supports custom CSS Class, Element ID, Inline Style, and Custom HTML Attributes.
*/ */
class CMSPluginTool { class CMSPluginTool {
static get toolbox() { static get toolbox() {
@@ -16,7 +17,11 @@ class CMSPluginTool {
const initialVal = (data && (data.id || data.shortcode)) ? (data.id || data.shortcode) : '[plugin:price-table]'; const initialVal = (data && (data.id || data.shortcode)) ? (data.id || data.shortcode) : '[plugin:price-table]';
this.data = { this.data = {
id: initialVal, id: initialVal,
shortcode: initialVal shortcode: initialVal,
cssClass: (data && data.cssClass) ? data.cssClass : '',
elementId: (data && data.elementId) ? data.elementId : '',
customStyle: (data && data.customStyle) ? data.customStyle : '',
customAttrs: (data && data.customAttrs) ? data.customAttrs : ''
}; };
this.wrapper = null; this.wrapper = null;
} }
@@ -24,7 +29,7 @@ class CMSPluginTool {
render() { render() {
this.wrapper = document.createElement('div'); this.wrapper = document.createElement('div');
this.wrapper.style.border = '1px solid #4e73df'; this.wrapper.style.border = '1px solid #4e73df';
this.wrapper.style.borderRadius = '6px'; this.wrapper.style.borderRadius = '8px';
this.wrapper.style.background = '#f8fafc'; this.wrapper.style.background = '#f8fafc';
this.wrapper.style.padding = '15px'; this.wrapper.style.padding = '15px';
this.wrapper.style.marginBottom = '12px'; this.wrapper.style.marginBottom = '12px';
@@ -33,11 +38,11 @@ class CMSPluginTool {
header.style.display = 'flex'; header.style.display = 'flex';
header.style.justifyContent = 'space-between'; header.style.justifyContent = 'space-between';
header.style.alignItems = 'center'; header.style.alignItems = 'center';
header.style.marginBottom = '10px'; header.style.marginBottom = '12px';
const title = document.createElement('label'); const title = document.createElement('label');
title.className = 'font-weight-bold text-primary m-0'; title.className = 'font-weight-bold text-primary m-0';
title.innerHTML = '🧩 CMS Plugin Shortcode Injection'; title.innerHTML = '<i class="fas fa-cubes mr-1"></i> CMS Plugin Shortcode & Custom Attributes';
title.style.fontSize = '14px'; title.style.fontSize = '14px';
header.appendChild(title); header.appendChild(title);
@@ -48,12 +53,11 @@ class CMSPluginTool {
selectContainer.style.marginBottom = '10px'; selectContainer.style.marginBottom = '10px';
const selectLabel = document.createElement('label'); const selectLabel = document.createElement('label');
selectLabel.className = 'small text-muted font-weight-bold'; selectLabel.className = 'small text-muted font-weight-bold mb-1';
selectLabel.innerText = 'Chọn Plugin có sẵn:'; selectLabel.innerText = 'Chọn Plugin có sẵn:';
const select = document.createElement('select'); const select = document.createElement('select');
select.className = 'form-control form-control-sm'; select.className = 'form-control form-control-sm mb-2';
select.style.marginBottom = '8px';
select.innerHTML = ` select.innerHTML = `
<option value="">-- Chọn Shortcode Plugin --</option> <option value="">-- Chọn Shortcode Plugin --</option>
@@ -62,6 +66,8 @@ class CMSPluginTool {
<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: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]">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='[plugin:price-table id="table_1"]'>Bảng giá theo ID [plugin:price-table id="table_1"]</option>
<option value='[plugin:google-form]'>Form Tri Ân Mặc định [plugin:google-form]</option>
<option value='[plugin:google-form id="form-1"]'>Google Form / Tri Ân theo ID [plugin:google-form id="form-1"]</option>
<option value="custom">-- Nhập Shortcode Tùy chỉnh --</option> <option value="custom">-- Nhập Shortcode Tùy chỉnh --</option>
`; `;
@@ -69,42 +75,114 @@ class CMSPluginTool {
selectContainer.appendChild(select); selectContainer.appendChild(select);
this.wrapper.appendChild(selectContainer); this.wrapper.appendChild(selectContainer);
// Input Field // Shortcode Input Field
const inputLabel = document.createElement('label'); const inputLabel = document.createElement('label');
inputLabel.className = 'small text-muted font-weight-bold'; inputLabel.className = 'small text-muted font-weight-bold mb-1';
inputLabel.innerText = 'Mã Shortcode Plugin:'; inputLabel.innerText = 'Mã Shortcode Plugin:';
const input = document.createElement('input'); this.shortcodeInput = document.createElement('input');
input.type = 'text'; this.shortcodeInput.type = 'text';
input.className = 'form-control font-weight-bold'; this.shortcodeInput.className = 'form-control form-control-sm font-weight-bold mb-3';
input.placeholder = 'e.g. [plugin:price-table id="table_1"]'; this.shortcodeInput.placeholder = 'e.g. [plugin:price-table id="table_1"]';
input.value = this.data.shortcode || this.data.id || ''; this.shortcodeInput.value = this.data.shortcode || this.data.id || '';
if (this.readOnly) input.disabled = true; if (this.readOnly) this.shortcodeInput.disabled = true;
select.addEventListener('change', (e) => { select.addEventListener('change', (e) => {
const val = e.target.value; const val = e.target.value;
if (val && val !== 'custom') { if (val && val !== 'custom') {
input.value = val; this.shortcodeInput.value = val;
this.data.shortcode = val; this.data.shortcode = val;
this.data.id = val; this.data.id = val;
} }
}); });
input.addEventListener('input', (e) => { this.shortcodeInput.addEventListener('input', (e) => {
this.data.shortcode = e.target.value.trim(); this.data.shortcode = e.target.value.trim();
this.data.id = e.target.value.trim(); this.data.id = e.target.value.trim();
}); });
this.wrapper.appendChild(inputLabel); this.wrapper.appendChild(inputLabel);
this.wrapper.appendChild(input); this.wrapper.appendChild(this.shortcodeInput);
// Custom HTML Settings (CSS Class, Element ID, Style, Attributes)
const attrHeading = document.createElement('div');
attrHeading.className = 'small font-weight-bold text-secondary border-top pt-2 mt-2 mb-2';
attrHeading.innerHTML = '<i class="fas fa-sliders-h mr-1"></i> Tùy chỉnh CSS Class, Element ID, Style & Attribute';
this.wrapper.appendChild(attrHeading);
const row1 = document.createElement('div');
row1.className = 'form-row mb-2';
// CSS Class Input
const classCol = document.createElement('div');
classCol.className = 'col-md-6 mb-2';
classCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">CSS Class (class)</label>';
this.cssClassInput = document.createElement('input');
this.cssClassInput.type = 'text';
this.cssClassInput.className = 'form-control form-control-sm';
this.cssClassInput.placeholder = 'e.g. custom-cms-wrapper py-4';
this.cssClassInput.value = this.data.cssClass || '';
if (this.readOnly) this.cssClassInput.disabled = true;
classCol.appendChild(this.cssClassInput);
// Element ID Input
const idCol = document.createElement('div');
idCol.className = 'col-md-6 mb-2';
idCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Element ID (id)</label>';
this.elementIdInput = document.createElement('input');
this.elementIdInput.type = 'text';
this.elementIdInput.className = 'form-control form-control-sm';
this.elementIdInput.placeholder = 'e.g. section-cms-plugin-1';
this.elementIdInput.value = this.data.elementId || '';
if (this.readOnly) this.elementIdInput.disabled = true;
idCol.appendChild(this.elementIdInput);
row1.appendChild(classCol);
row1.appendChild(idCol);
this.wrapper.appendChild(row1);
const row2 = document.createElement('div');
row2.className = 'form-row';
// Custom Style Input
const styleCol = document.createElement('div');
styleCol.className = 'col-md-6 mb-2';
styleCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Custom Style (style)</label>';
this.customStyleInput = document.createElement('input');
this.customStyleInput.type = 'text';
this.customStyleInput.className = 'form-control form-control-sm';
this.customStyleInput.placeholder = 'e.g. background: #f8fafc; border-radius: 12px;';
this.customStyleInput.value = this.data.customStyle || '';
if (this.readOnly) this.customStyleInput.disabled = true;
styleCol.appendChild(this.customStyleInput);
// Custom Attributes Input
const attrsCol = document.createElement('div');
attrsCol.className = 'col-md-6 mb-2';
attrsCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Custom Attributes</label>';
this.customAttrsInput = document.createElement('input');
this.customAttrsInput.type = 'text';
this.customAttrsInput.className = 'form-control form-control-sm';
this.customAttrsInput.placeholder = 'e.g. data-aos="fade-up" data-ref="123"';
this.customAttrsInput.value = this.data.customAttrs || '';
if (this.readOnly) this.customAttrsInput.disabled = true;
attrsCol.appendChild(this.customAttrsInput);
row2.appendChild(styleCol);
row2.appendChild(attrsCol);
this.wrapper.appendChild(row2);
return this.wrapper; return this.wrapper;
} }
save() { save() {
return { return {
id: this.data.id || this.data.shortcode || '', id: this.shortcodeInput ? this.shortcodeInput.value.trim() : (this.data.id || this.data.shortcode || ''),
shortcode: this.data.shortcode || this.data.id || '' shortcode: this.shortcodeInput ? this.shortcodeInput.value.trim() : (this.data.shortcode || this.data.id || ''),
cssClass: this.cssClassInput ? this.cssClassInput.value.trim() : (this.data.cssClass || ''),
elementId: this.elementIdInput ? this.elementIdInput.value.trim() : (this.data.elementId || ''),
customStyle: this.customStyleInput ? this.customStyleInput.value.trim() : (this.data.customStyle || ''),
customAttrs: this.customAttrsInput ? this.customAttrsInput.value.trim() : (this.data.customAttrs || '')
}; };
} }
} }
@@ -114,3 +192,5 @@ window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['cmsPlugin'] = { window.SISEditorPlugins['cmsPlugin'] = {
class: CMSPluginTool class: CMSPluginTool
}; };
window.CMSPluginTool = CMSPluginTool;
@@ -22,6 +22,7 @@ class FlexTool {
gap: data.gap || '3', // standard Bootstrap gap level gap: data.gap || '3', // standard Bootstrap gap level
globalClass: data.globalClass || '', globalClass: data.globalClass || '',
globalId: data.globalId || '', globalId: data.globalId || '',
globalStyle: data.globalStyle || data.style || '',
globalAttributes: data.globalAttributes || data.attributes || '' globalAttributes: data.globalAttributes || data.attributes || ''
}; };
this.activeInstances = {}; this.activeInstances = {};
@@ -36,9 +37,11 @@ class FlexTool {
this.data[`accContent${i}`] = data[`accContent${i}`] || ''; this.data[`accContent${i}`] = data[`accContent${i}`] || '';
this.data[`class${i}`] = data[`class${i}`] || ''; this.data[`class${i}`] = data[`class${i}`] || '';
this.data[`id${i}`] = data[`id${i}`] || ''; this.data[`id${i}`] = data[`id${i}`] || '';
this.data[`style${i}`] = data[`style${i}`] || data[`customStyle${i}`] || '';
this.data[`attr${i}`] = data[`attr${i}`] || data[`attributes${i}`] || ''; this.data[`attr${i}`] = data[`attr${i}`] || data[`attributes${i}`] || '';
this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto
this.data[`caption${i}`] = data[`caption${i}`] || ''; this.data[`caption${i}`] = data[`caption${i}`] || '';
this.data[`stylesub_${i}`] = data[`stylesub_${i}`] || '';
} }
this.wrapper = undefined; this.wrapper = undefined;
} }
@@ -227,7 +230,22 @@ class FlexTool {
idDiv.appendChild(idInput); idDiv.appendChild(idInput);
bgSettingsRow.appendChild(idDiv); bgSettingsRow.appendChild(idDiv);
// 4. Global Custom Attributes // 4. Global 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">Flex Custom Style</label>';
const styleInput = document.createElement('input');
styleInput.type = 'text';
styleInput.className = 'form-control form-control-sm';
styleInput.placeholder = 'e.g. color: red; background: #fff;';
styleInput.value = this.data.globalStyle || '';
if (this.readOnly) styleInput.disabled = true;
styleInput.addEventListener('input', (e) => this.data.globalStyle = e.target.value.trim());
this.globalStyleInput = styleInput;
styleDiv.appendChild(styleInput);
bgSettingsRow.appendChild(styleDiv);
// 5. Global Custom Attributes
const attrDiv = document.createElement('div'); const attrDiv = document.createElement('div');
attrDiv.className = 'col-md-3 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>'; attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Custom Attributes</label>';
@@ -1381,6 +1399,7 @@ class FlexTool {
gap: this.data.gap, gap: this.data.gap,
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || ''), globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || ''),
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''), globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
globalStyle: this.globalStyleInput ? this.globalStyleInput.value.trim() : (this.data.globalStyle || ''),
globalAttributes: this.globalAttrInput ? this.globalAttrInput.value.trim() : (this.data.globalAttributes || '') globalAttributes: this.globalAttrInput ? this.globalAttrInput.value.trim() : (this.data.globalAttributes || '')
}; };
for (let i = 1; i <= this.data.cols; i++) { for (let i = 1; i <= this.data.cols; i++) {
@@ -0,0 +1,405 @@
/**
* SISGoogleFormTool — Form & Tri Ân Block Plugin for Editor.js
* Allows content managers to build interactive forms (e.g., "Chia sẻ lời tri ân", Contact forms)
* or embed Google Forms / external form iFrames with live preview and theme options.
*/
(function () {
'use strict';
class SISGoogleFormTool {
static get toolbox() {
return {
title: 'Form & Tri Ân',
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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
formType: (data && data.formType) ? data.formType : 'tri_an',
title: (data && data.title) ? data.title : 'Chia sẻ lời tri ân',
subtitle: (data && data.subtitle) ? data.subtitle : '',
buttonText: (data && data.buttonText) ? data.buttonText : 'Gửi thông tin',
successMsg: (data && data.successMsg) ? data.successMsg : 'Gửi thông tin thành công!',
actionUrl: (data && data.actionUrl) ? data.actionUrl : '',
iframeUrl: (data && data.iframeUrl) ? data.iframeUrl : '',
iframeHeight: (data && data.iframeHeight) ? data.iframeHeight : '650px',
theme: (data && data.theme) ? data.theme : 'blue_gradient',
fullWidth: !!(data && (data.fullWidth || data.stretched))
};
}
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 headerLabel = document.createElement('label');
headerLabel.className = 'font-weight-bold text-primary small mb-2 d-block';
headerLabel.innerHTML = '<i class="fas fa-wpforms mr-1"></i> Interactive Form & Google Form Block Settings';
container.appendChild(headerLabel);
// Row 1: Form Type & Theme
const row1 = document.createElement('div');
row1.className = 'form-row mb-2';
const typeCol = document.createElement('div');
typeCol.className = 'col-md-6 mb-2';
typeCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Form Type / Template</label>';
const typeSelect = document.createElement('select');
typeSelect.className = 'form-control form-control-sm';
typeSelect.innerHTML = `
<option value="tri_an" ${this.data.formType === 'tri_an' ? 'selected' : ''}>Form Chia Sẻ Lời Tri Ân (Tương tác)</option>
<option value="contact" ${this.data.formType === 'contact' ? 'selected' : ''}>Form Liên Hệ & Góp Ý (Tương tác)</option>
<option value="google_iframe" ${this.data.formType === 'google_iframe' ? 'selected' : ''}>Google Form / External iFrame Embed</option>
`;
typeCol.appendChild(typeSelect);
const themeCol = document.createElement('div');
themeCol.className = 'col-md-6 mb-2';
themeCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Color Theme</label>';
const themeSelect = document.createElement('select');
themeSelect.className = 'form-control form-control-sm';
themeSelect.innerHTML = `
<option value="blue_gradient" ${this.data.theme === 'blue_gradient' ? 'selected' : ''}>🔵 Blue Gradient (Đại học Y Dược HCM)</option>
<option value="red_gradient" ${this.data.theme === 'red_gradient' ? 'selected' : ''}>🔴 Red Gradient (S.I.S Cần Thơ)</option>
<option value="teal_gradient" ${this.data.theme === 'teal_gradient' ? 'selected' : ''}>🟢 Teal Medical Gradient</option>
<option value="light" ${this.data.theme === 'light' ? 'selected' : ''}>⚪ Light Minimal Card</option>
`;
themeCol.appendChild(themeSelect);
row1.appendChild(typeCol);
row1.appendChild(themeCol);
container.appendChild(row1);
// Controls container for interactive vs iframe modes
const customFieldsContainer = document.createElement('div');
// Title & Subtitle inputs
const row2 = document.createElement('div');
row2.className = 'form-row mb-2';
const titleCol = document.createElement('div');
titleCol.className = 'col-md-6 mb-2';
titleCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Form Title</label>';
this.titleInput = document.createElement('input');
this.titleInput.type = 'text';
this.titleInput.className = 'form-control form-control-sm';
this.titleInput.placeholder = 'e.g. Chia sẻ lời tri ân';
this.titleInput.value = this.data.title;
titleCol.appendChild(this.titleInput);
const subtitleCol = document.createElement('div');
subtitleCol.className = 'col-md-6 mb-2';
subtitleCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Subtitle / Description (Optional)</label>';
this.subtitleInput = document.createElement('input');
this.subtitleInput.type = 'text';
this.subtitleInput.className = 'form-control form-control-sm';
this.subtitleInput.placeholder = 'e.g. Gửi lời nhắn đến các bác sĩ và nhân viên y tế...';
this.subtitleInput.value = this.data.subtitle;
subtitleCol.appendChild(this.subtitleInput);
row2.appendChild(titleCol);
row2.appendChild(subtitleCol);
customFieldsContainer.appendChild(row2);
// Button text, Success msg, Action URL
const row3 = document.createElement('div');
row3.className = 'form-row mb-2';
const btnCol = document.createElement('div');
btnCol.className = 'col-md-4 mb-2';
btnCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Submit Button Text</label>';
this.btnTextInput = document.createElement('input');
this.btnTextInput.type = 'text';
this.btnTextInput.className = 'form-control form-control-sm';
this.btnTextInput.placeholder = 'e.g. Gửi thông tin';
this.btnTextInput.value = this.data.buttonText;
btnCol.appendChild(this.btnTextInput);
const successCol = document.createElement('div');
successCol.className = 'col-md-4 mb-2';
successCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Success Alert Message</label>';
this.successInput = document.createElement('input');
this.successInput.type = 'text';
this.successInput.className = 'form-control form-control-sm';
this.successInput.placeholder = 'e.g. Gửi thông tin thành công!';
this.successInput.value = this.data.successMsg;
successCol.appendChild(this.successInput);
const actionCol = document.createElement('div');
actionCol.className = 'col-md-4 mb-2';
actionCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Form Submit API URL (Optional)</label>';
this.actionUrlInput = document.createElement('input');
this.actionUrlInput.type = 'text';
this.actionUrlInput.className = 'form-control form-control-sm';
this.actionUrlInput.placeholder = 'e.g. /api/tri-an/submit';
this.actionUrlInput.value = this.data.actionUrl;
actionCol.appendChild(this.actionUrlInput);
row3.appendChild(btnCol);
row3.appendChild(successCol);
row3.appendChild(actionCol);
customFieldsContainer.appendChild(row3);
container.appendChild(customFieldsContainer);
// Google Form iFrame controls
const iframeFieldsContainer = document.createElement('div');
iframeFieldsContainer.className = 'form-row mb-2';
iframeFieldsContainer.style.display = this.data.formType === 'google_iframe' ? '' : 'none';
const iframeUrlCol = document.createElement('div');
iframeUrlCol.className = 'col-md-8 mb-2';
iframeUrlCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Google Form Embed URL (src)</label>';
this.iframeUrlInput = document.createElement('input');
this.iframeUrlInput.type = 'text';
this.iframeUrlInput.className = 'form-control form-control-sm';
this.iframeUrlInput.placeholder = 'https://docs.google.com/forms/d/e/.../viewform?embedded=true';
this.iframeUrlInput.value = this.data.iframeUrl;
iframeUrlCol.appendChild(this.iframeUrlInput);
const iframeHeightCol = document.createElement('div');
iframeHeightCol.className = 'col-md-4 mb-2';
iframeHeightCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">iFrame Height (e.g. 650px, 800px)</label>';
this.iframeHeightInput = document.createElement('input');
this.iframeHeightInput.type = 'text';
this.iframeHeightInput.className = 'form-control form-control-sm';
this.iframeHeightInput.placeholder = '650px';
this.iframeHeightInput.value = this.data.iframeHeight;
iframeHeightCol.appendChild(this.iframeHeightInput);
iframeFieldsContainer.appendChild(iframeUrlCol);
iframeFieldsContainer.appendChild(iframeHeightCol);
container.appendChild(iframeFieldsContainer);
// Stretch toggle
const stretchWrapper = document.createElement('div');
stretchWrapper.className = 'custom-control custom-switch mb-3 mt-1';
this.fullWidthCheck = document.createElement('input');
this.fullWidthCheck.type = 'checkbox';
this.fullWidthCheck.className = 'custom-control-input';
this.fullWidthCheck.id = 'form_stretch_' + Math.random().toString(36).substring(7);
this.fullWidthCheck.checked = !!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 mr-1"></i> Stretch Form Container to Full Screen Width';
this.fullWidthCheck.addEventListener('change', () => {
this.data.fullWidth = this.fullWidthCheck.checked;
});
stretchWrapper.appendChild(this.fullWidthCheck);
stretchWrapper.appendChild(stretchLabel);
container.appendChild(stretchWrapper);
// Live Preview Box
const previewLabel = document.createElement('div');
previewLabel.className = 'small font-weight-bold text-secondary mb-2';
previewLabel.innerHTML = '<i class="fas fa-eye mr-1"></i> Live Form Preview:';
container.appendChild(previewLabel);
const previewBox = document.createElement('div');
previewBox.style.cssText = 'border:1px dashed #cbd5e1; border-radius:12px; overflow:hidden; margin-bottom:10px; background:#f8fafc;';
container.appendChild(previewBox);
const getThemeBackground = (themeKey) => {
switch (themeKey) {
case 'red_gradient':
return 'linear-gradient(135deg, #701a1a 0%, #d7193f 50%, #fff0f3 100%)';
case 'teal_gradient':
return 'linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #f0fdfa 100%)';
case 'light':
return '#f1f5f9';
case 'blue_gradient':
default:
return 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #eff6ff 100%)';
}
};
const getThemeHeaderColor = (themeKey) => {
switch (themeKey) {
case 'red_gradient': return '#881C1C';
case 'teal_gradient': return '#0f766e';
case 'light': return '#1e293b';
case 'blue_gradient':
default: return '#1e3a8a';
}
};
const getThemeBtnClass = (themeKey) => {
switch (themeKey) {
case 'red_gradient': return 'background:#d7193f; color:#fff;';
case 'teal_gradient': return 'background:#0d9488; color:#fff;';
case 'light': return 'background:#1e293b; color:#fff;';
case 'blue_gradient':
default: return 'background:#2563eb; color:#fff;';
}
};
const updatePreview = () => {
const fType = typeSelect.value;
const themeVal = themeSelect.value;
const titleVal = this.titleInput.value.trim() || 'Chia sẻ lời tri ân';
const subVal = this.subtitleInput.value.trim();
const btnVal = this.btnTextInput.value.trim() || 'Gửi thông tin';
const iframeUrlVal = this.iframeUrlInput.value.trim();
const iframeHVal = this.iframeHeightInput.value.trim() || '650px';
this.data.formType = fType;
this.data.theme = themeVal;
if (fType === 'google_iframe') {
customFieldsContainer.style.display = 'none';
iframeFieldsContainer.style.display = '';
if (iframeUrlVal) {
previewBox.innerHTML = `
<div style="padding:15px; background:${getThemeBackground(themeVal)}; min-height:300px; display:flex; justify-content:center; align-items:center;">
<iframe src="${iframeUrlVal}" style="width:100%; height:${iframeHVal}; border:none; border-radius:12px; background:#fff; box-shadow:0 10px 25px rgba(0,0,0,0.15);"></iframe>
</div>
`;
} else {
previewBox.innerHTML = `
<div style="padding:40px 20px; text-align:center; color:#64748b;">
<i class="fab fa-google fa-2x mb-2 text-primary" style="display:block;"></i>
<p class="small font-weight-bold m-0">Vui lòng nhập Google Form Embed URL (src) để xem bản trước.</p>
</div>
`;
}
} else if (fType === 'tri_an') {
customFieldsContainer.style.display = '';
iframeFieldsContainer.style.display = 'none';
previewBox.innerHTML = `
<div style="background:${getThemeBackground(themeVal)}; padding:24px 16px; min-height:100%; display:flex; justify-content:center; align-items:center;">
<div style="background:#ffffff; border-radius:16px; box-shadow:0 20px 40px rgba(0,0,0,0.15); width:100%; max-width:540px; padding:24px; font-family:system-ui,-apple-system,sans-serif;">
<h3 style="font-size:20px; font-weight:700; color:${getThemeHeaderColor(themeVal)}; margin-top:0; margin-bottom:${subVal ? '6px' : '18px'};">${titleVal}</h3>
${subVal ? `<p style="font-size:13px; color:#64748b; margin-top:0; margin-bottom:18px;">${subVal}</p>` : ''}
<div style="display:flex; flex-direction:column; gap:12px;">
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Họ tên <span style="color:#ef4444;">*</span></label>
<input type="text" disabled placeholder="Nhập họ tên" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa;">
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Bạn là <span style="color:#ef4444;">*</span></label>
<select disabled style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa; color:#6b7280;">
<option>Chọn vai trò</option>
<option>Bác sĩ / Nhân viên y tế</option>
<option>Bệnh nhân / Người nhà</option>
<option>Khác</option>
</select>
</div>
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Chức vụ / Nơi làm việc <span style="color:#ef4444;">*</span></label>
<input type="text" disabled placeholder="Nhập chức vụ/ nơi làm việc" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa;">
</div>
</div>
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Số điện thoại <span style="color:#ef4444;">*</span></label>
<input type="tel" disabled placeholder="Nhập số điện thoại" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa;">
</div>
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Nội dung lời nhắn <span style="color:#ef4444;">*</span></label>
<textarea disabled rows="3" placeholder="Nhập nội dung" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa; resize:none;"></textarea>
</div>
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Hình ảnh <span style="color:#ef4444;">*</span></label>
<div style="width:90px; height:90px; border:2px dashed #d1d5db; border-radius:8px; display:flex; flex-direction:column; align-items:center; justify-content:center; background:#f9fafb; color:#9ca3af;">
<i class="fas fa-plus mb-1"></i>
<span style="font-size:11px;">Tải tệp</span>
</div>
</div>
<div style="padding-top:6px;">
<button disabled type="button" style="padding:10px 24px; border:none; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; ${getThemeBtnClass(themeVal)}">${btnVal}</button>
</div>
</div>
</div>
</div>
`;
} else if (fType === 'contact') {
customFieldsContainer.style.display = '';
iframeFieldsContainer.style.display = 'none';
previewBox.innerHTML = `
<div style="background:${getThemeBackground(themeVal)}; padding:24px 16px; min-height:100%; display:flex; justify-content:center; align-items:center;">
<div style="background:#ffffff; border-radius:16px; box-shadow:0 20px 40px rgba(0,0,0,0.15); width:100%; max-width:540px; padding:24px; font-family:system-ui,-apple-system,sans-serif;">
<h3 style="font-size:20px; font-weight:700; color:${getThemeHeaderColor(themeVal)}; margin-top:0; margin-bottom:${subVal ? '6px' : '18px'};">${titleVal}</h3>
${subVal ? `<p style="font-size:13px; color:#64748b; margin-top:0; margin-bottom:18px;">${subVal}</p>` : ''}
<div style="display:flex; flex-direction:column; gap:12px;">
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Họ và tên <span style="color:#ef4444;">*</span></label>
<input type="text" disabled placeholder="Nhập họ và tên của bạn" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa;">
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Email <span style="color:#ef4444;">*</span></label>
<input type="email" disabled placeholder="email@example.com" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa;">
</div>
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Số điện thoại</label>
<input type="tel" disabled placeholder="Nhập số điện thoại" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa;">
</div>
</div>
<div>
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Nội dung tin nhắn <span style="color:#ef4444;">*</span></label>
<textarea disabled rows="3" placeholder="Nhập nội dung cần hỗ trợ hoặc góp ý..." style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa; resize:none;"></textarea>
</div>
<div style="padding-top:6px;">
<button disabled type="button" style="padding:10px 24px; border:none; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; ${getThemeBtnClass(themeVal)}">${btnVal}</button>
</div>
</div>
</div>
</div>
`;
}
};
// Event Listeners
typeSelect.addEventListener('change', updatePreview);
themeSelect.addEventListener('change', updatePreview);
[this.titleInput, this.subtitleInput, this.btnTextInput, this.successInput, this.actionUrlInput, this.iframeUrlInput, this.iframeHeightInput].forEach(inp => {
if (inp) {
inp.addEventListener('input', updatePreview);
if (this.readOnly) inp.disabled = true;
}
});
if (this.readOnly) {
typeSelect.disabled = true;
themeSelect.disabled = true;
this.fullWidthCheck.disabled = true;
}
updatePreview();
return container;
}
save() {
return {
formType: this.data.formType || 'tri_an',
title: this.titleInput ? this.titleInput.value : this.data.title,
subtitle: this.subtitleInput ? this.subtitleInput.value : this.data.subtitle,
buttonText: this.btnTextInput ? this.btnTextInput.value : this.data.buttonText,
successMsg: this.successInput ? this.successInput.value : this.data.successMsg,
actionUrl: this.actionUrlInput ? this.actionUrlInput.value : this.data.actionUrl,
iframeUrl: this.iframeUrlInput ? this.iframeUrlInput.value : this.data.iframeUrl,
iframeHeight: this.iframeHeightInput ? this.iframeHeightInput.value : this.data.iframeHeight,
theme: this.data.theme || 'blue_gradient',
fullWidth: this.fullWidthCheck ? this.fullWidthCheck.checked : !!this.data.fullWidth
};
}
}
// Register Plugin globally in window.SISEditorPlugins
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['googleForm'] = {
class: SISGoogleFormTool
};
window.SISGoogleFormTool = SISGoogleFormTool;
})();
@@ -1,6 +1,7 @@
/** /**
* SISHeroBannerTool — Hero Banner Plugin for Editor.js * SISHeroBannerTool — Hero Banner Plugin for Editor.js
* Allows content managers to build dynamic hero banners with live preview. * Allows content managers to build dynamic hero banners with live preview.
* Supports rich gradient overlays over the background image with full direction control.
*/ */
class SISHeroBannerTool { class SISHeroBannerTool {
static get toolbox() { static get toolbox() {
@@ -10,6 +11,39 @@ class SISHeroBannerTool {
}; };
} }
// ─── Gradient Presets ────────────────────────────────────────────────────────
static get GRADIENT_PRESETS() {
return [
{ id: 'none', label: 'None (Solid Overlay)', build: (c1, c2, op, angle) => `rgba(0,0,0,${op})` },
{ id: 'linear', label: 'Linear Gradient', build: (c1, c2, op, angle) => `linear-gradient(${angle}deg, ${SISHeroBannerTool._hex2rgba(c1, op)}, ${SISHeroBannerTool._hex2rgba(c2, 0)})` },
{ id: 'linear_both', label: 'Linear (Both Colours)', build: (c1, c2, op, angle) => `linear-gradient(${angle}deg, ${SISHeroBannerTool._hex2rgba(c1, op)}, ${SISHeroBannerTool._hex2rgba(c2, op)})` },
{ id: 'radial', label: 'Radial Gradient', build: (c1, c2, op, angle, rx, ry) => `radial-gradient(ellipse at ${rx}% ${ry}%, ${SISHeroBannerTool._hex2rgba(c1, op)}, ${SISHeroBannerTool._hex2rgba(c2, 0)})` },
{ id: 'radial_both', label: 'Radial (Both Colours)', build: (c1, c2, op, angle, rx, ry) => `radial-gradient(ellipse at ${rx}% ${ry}%, ${SISHeroBannerTool._hex2rgba(c1, op)}, ${SISHeroBannerTool._hex2rgba(c2, op)})` },
{ id: 'dark_bottom', label: '▼ Dark from Bottom', build: (c1, c2, op) => `linear-gradient(to top, rgba(0,0,0,${op}) 0%, rgba(0,0,0,0) 60%)` },
{ id: 'dark_top', label: '▲ Dark from Top', build: (c1, c2, op) => `linear-gradient(to bottom, rgba(0,0,0,${op}) 0%, rgba(0,0,0,0) 60%)` },
{ id: 'dark_left', label: '◄ Dark from Left', build: (c1, c2, op) => `linear-gradient(to right, rgba(0,0,0,${op}) 0%, rgba(0,0,0,0) 60%)` },
{ id: 'dark_right', label: '► Dark from Right', build: (c1, c2, op) => `linear-gradient(to left, rgba(0,0,0,${op}) 0%, rgba(0,0,0,0) 60%)` },
{ id: 'spotlight', label: '● Spotlight Centre', build: (c1, c2, op) => `radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0) 20%, rgba(0,0,0,${op}) 80%)` },
{ id: 'vignette', label: '◎ Vignette (Edges)', build: (c1, c2, op) => `radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0) 40%, rgba(0,0,0,${op}) 100%)` },
{ id: 'duo_blue', label: '🎨 Duo-tone Blue', build: (c1, c2, op, angle) => `linear-gradient(${angle}deg, rgba(2,132,199,${op}), rgba(15,23,42,${op}))` },
{ id: 'duo_purple', label: '🎨 Duo-tone Purple', build: (c1, c2, op, angle) => `linear-gradient(${angle}deg, rgba(126,34,206,${op}), rgba(15,23,42,${op}))` },
{ id: 'duo_sunset', label: '🌅 Sunset', build: (c1, c2, op, angle) => `linear-gradient(${angle}deg, rgba(220,38,38,${op}), rgba(249,115,22,${op}), rgba(234,179,8,${op}))` },
{ id: 'duo_ocean', label: '🌊 Ocean', build: (c1, c2, op, angle) => `linear-gradient(${angle}deg, rgba(6,182,212,${op}), rgba(37,99,235,${op}))` },
{ id: 'duo_forest', label: '🌿 Forest', build: (c1, c2, op, angle) => `linear-gradient(${angle}deg, rgba(22,163,74,${op}), rgba(15,23,42,${op}))` },
{ id: 'duo_rose', label: '🌸 Rose Gold', build: (c1, c2, op, angle) => `linear-gradient(${angle}deg, rgba(244,63,94,${op}), rgba(251,191,36,${op}))` },
{ id: 'custom_color', label: '🖌 Custom Colour (pick)', build: (c1, c2, op, angle) => `linear-gradient(${angle}deg, ${SISHeroBannerTool._hex2rgba(c1, op)}, ${SISHeroBannerTool._hex2rgba(c2, op)})` },
];
}
static _hex2rgba(hex, alpha) {
hex = (hex || '#000000').replace('#', '');
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
constructor({ data, api, readOnly }) { constructor({ data, api, readOnly }) {
this.api = api; this.api = api;
this.readOnly = readOnly; this.readOnly = readOnly;
@@ -21,11 +55,25 @@ class SISHeroBannerTool {
btnLink: (data && data.btnLink) ? data.btnLink : '', btnLink: (data && data.btnLink) ? data.btnLink : '',
height: (data && data.height) ? data.height : '350px', height: (data && data.height) ? data.height : '350px',
textAlign: (data && data.textAlign) ? data.textAlign : 'center', textAlign: (data && data.textAlign) ? data.textAlign : 'center',
overlayOpacity: (data && data.overlayOpacity !== undefined) ? data.overlayOpacity : '0.4', overlayOpacity: (data && data.overlayOpacity !== undefined) ? data.overlayOpacity : '0.5',
stretched: (data && data.stretched !== undefined) ? !!data.stretched : true stretched: (data && data.stretched !== undefined) ? !!data.stretched : true,
// Advanced element attributes (class, id, style, custom attributes)
textColor: (data && data.textColor) ? data.textColor : '#ffffff',
cssClass: (data && data.cssClass) ? data.cssClass : '',
elementId: (data && data.elementId) ? data.elementId : '',
customStyle: (data && data.customStyle) ? data.customStyle : '',
customAttrs: (data && data.customAttrs) ? data.customAttrs : '',
// Gradient fields
gradientType: (data && data.gradientType) ? data.gradientType : 'dark_bottom',
gradientAngle: (data && data.gradientAngle !== undefined) ? data.gradientAngle : 160,
gradientRadialX: (data && data.gradientRadialX !== undefined) ? data.gradientRadialX : 50,
gradientRadialY: (data && data.gradientRadialY !== undefined) ? data.gradientRadialY : 50,
gradientColor1: (data && data.gradientColor1) ? data.gradientColor1 : '#000000',
gradientColor2: (data && data.gradientColor2) ? data.gradientColor2 : '#1e3a5f',
}; };
} }
// ─── render() ───────────────────────────────────────────────────────────────
render() { render() {
const container = document.createElement('div'); const container = document.createElement('div');
container.style.border = '1px solid #e3e6f0'; container.style.border = '1px solid #e3e6f0';
@@ -39,7 +87,7 @@ class SISHeroBannerTool {
headerLabel.innerHTML = '<i class="fas fa-image"></i> Hero Banner Block Settings'; headerLabel.innerHTML = '<i class="fas fa-image"></i> Hero Banner Block Settings';
container.appendChild(headerLabel); container.appendChild(headerLabel);
// Stretch toggle // ── Stretch toggle ───────────────────────────────────────────────────────
const stretchWrapper = document.createElement('div'); const stretchWrapper = document.createElement('div');
stretchWrapper.className = 'custom-control custom-switch mb-3'; stretchWrapper.className = 'custom-control custom-switch mb-3';
const stretchCheck = document.createElement('input'); const stretchCheck = document.createElement('input');
@@ -47,113 +95,355 @@ class SISHeroBannerTool {
stretchCheck.className = 'custom-control-input'; stretchCheck.className = 'custom-control-input';
stretchCheck.id = 'hero_stretch_' + Math.random().toString(36).substring(7); stretchCheck.id = 'hero_stretch_' + Math.random().toString(36).substring(7);
stretchCheck.checked = !!this.data.stretched; stretchCheck.checked = !!this.data.stretched;
const stretchLabel = document.createElement('label'); const stretchLabel = document.createElement('label');
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary'; stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
stretchLabel.htmlFor = stretchCheck.id; stretchLabel.htmlFor = stretchCheck.id;
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch Banner to Full Screen Width'; stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch Banner to Full Screen Width';
stretchCheck.addEventListener('change', () => { this.data.stretched = stretchCheck.checked; });
stretchCheck.addEventListener('change', () => {
this.data.stretched = stretchCheck.checked;
});
stretchWrapper.appendChild(stretchCheck); stretchWrapper.appendChild(stretchCheck);
stretchWrapper.appendChild(stretchLabel); stretchWrapper.appendChild(stretchLabel);
container.appendChild(stretchWrapper); container.appendChild(stretchWrapper);
// Inputs // ── Text inputs ──────────────────────────────────────────────────────────
this.titleInput = this._createInput('Banner Title', 'e.g. Bệnh Viện Đa Khoa Quốc Tế S.I.S Cần Thơ', this.data.title); this.titleInput = this._createInput('Banner Title', 'e.g. Bệnh Viện Đa Khoa Quốc Tế S.I.S Cần Thơ', this.data.title);
this.subtitleInput = this._createInput('Subtitle / Description', 'e.g. Trao niềm tin - Nhận sức khỏe...', this.data.subtitle); this.subtitleInput = this._createInput('Subtitle / Description', 'e.g. Trao niềm tin - Nhận sức khỏe...', this.data.subtitle);
this.bgImageInput = this._createImageInput('Background Image URL', 'https://example.com/hero-banner.jpg', this.data.bgImage); this.bgImageInput = this._createMediaPickerInput('Background Image URL', 'https://example.com/hero-banner.jpg', this.data.bgImage);
// Row for Height & Button Link // ── Height + Text Colour + Button row ────────────────────────────────────
const configRow = document.createElement('div'); const configRow = document.createElement('div');
configRow.className = 'form-row'; configRow.className = 'form-row';
const heightCol = document.createElement('div'); const heightCol = document.createElement('div'); heightCol.className = 'col-md-3 mb-2';
heightCol.className = 'col-md-4 mb-2'; this.heightInput = this._createInput('Banner Height', 'e.g. 350px', this.data.height);
this.heightInput = this._createInput('Banner Height (e.g. 350px, 500px, 60vh)', 'e.g. 350px', this.data.height);
heightCol.appendChild(this.heightInput); heightCol.appendChild(this.heightInput);
const btnCol1 = document.createElement('div'); const textColorCol = document.createElement('div'); textColorCol.className = 'col-md-3 mb-2';
btnCol1.className = 'col-md-4 mb-2'; this.textColorInput = this._createColorPickerInput('Text Colour', this.data.textColor || '#ffffff');
textColorCol.appendChild(this.textColorInput);
const btnCol1 = document.createElement('div'); btnCol1.className = 'col-md-3 mb-2';
this.btnTextInput = this._createInput('Button Text (Optional)', 'e.g. Đặt Lịch Khám', this.data.btnText); this.btnTextInput = this._createInput('Button Text (Optional)', 'e.g. Đặt Lịch Khám', this.data.btnText);
btnCol1.appendChild(this.btnTextInput); btnCol1.appendChild(this.btnTextInput);
const btnCol2 = document.createElement('div'); const btnCol2 = document.createElement('div'); btnCol2.className = 'col-md-3 mb-2';
btnCol2.className = 'col-md-4 mb-2';
this.btnLinkInput = this._createInput('Button Link URL (Optional)', 'e.g. /dat-lich', this.data.btnLink); this.btnLinkInput = this._createInput('Button Link URL (Optional)', 'e.g. /dat-lich', this.data.btnLink);
btnCol2.appendChild(this.btnLinkInput); btnCol2.appendChild(this.btnLinkInput);
configRow.appendChild(heightCol); configRow.appendChild(heightCol);
configRow.appendChild(textColorCol);
configRow.appendChild(btnCol1); configRow.appendChild(btnCol1);
configRow.appendChild(btnCol2); configRow.appendChild(btnCol2);
// Live Preview Box // ── Gradient Controls ────────────────────────────────────────────────────
const gradSection = document.createElement('div');
gradSection.style.cssText = 'background:#f8f9fc;border:1px solid #e3e6f0;border-radius:6px;padding:12px;margin-bottom:12px;';
const gradHeader = document.createElement('div');
gradHeader.className = 'small font-weight-bold text-secondary mb-2';
gradHeader.innerHTML = '<i class="fas fa-magic mr-1"></i> Gradient Overlay Settings';
gradSection.appendChild(gradHeader);
// Row 1: type selector + opacity
const gradRow1 = document.createElement('div');
gradRow1.className = 'form-row align-items-end mb-2';
// Gradient type
const gradTypeCol = document.createElement('div'); gradTypeCol.className = 'col-md-6 mb-2';
const gradTypeLabel = document.createElement('label');
gradTypeLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';
gradTypeLabel.textContent = 'Gradient Type';
const gradTypeSelect = document.createElement('select');
gradTypeSelect.className = 'form-control form-control-sm';
SISHeroBannerTool.GRADIENT_PRESETS.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.label;
if (p.id === this.data.gradientType) opt.selected = true;
gradTypeSelect.appendChild(opt);
});
gradTypeCol.appendChild(gradTypeLabel);
gradTypeCol.appendChild(gradTypeSelect);
gradRow1.appendChild(gradTypeCol);
// Opacity
const gradOpCol = document.createElement('div'); gradOpCol.className = 'col-md-6 mb-2';
const gradOpLabel = document.createElement('label');
gradOpLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';
const opValSpan = document.createElement('span');
opValSpan.textContent = Math.round(parseFloat(this.data.overlayOpacity) * 100) + '%';
gradOpLabel.innerHTML = 'Overlay Opacity — ';
gradOpLabel.appendChild(opValSpan);
const gradOpInput = document.createElement('input');
gradOpInput.type = 'range';
gradOpInput.className = 'form-control-range';
gradOpInput.min = '0'; gradOpInput.max = '1'; gradOpInput.step = '0.05';
gradOpInput.value = this.data.overlayOpacity;
gradOpInput.addEventListener('input', () => {
this.data.overlayOpacity = gradOpInput.value;
opValSpan.textContent = Math.round(parseFloat(gradOpInput.value) * 100) + '%';
updatePreview();
});
gradOpCol.appendChild(gradOpLabel);
gradOpCol.appendChild(gradOpInput);
gradRow1.appendChild(gradOpCol);
gradSection.appendChild(gradRow1);
// Row 2: direction controls (angle or radial XY) + colour pickers
const gradRow2 = document.createElement('div');
gradRow2.className = 'form-row align-items-end mb-2';
// -- Linear angle --
const angleCol = document.createElement('div'); angleCol.className = 'col-md-3 mb-2';
const angleLabel = document.createElement('label');
angleLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';
const angleValSpan = document.createElement('span');
angleValSpan.textContent = this.data.gradientAngle + '°';
angleLabel.innerHTML = 'Direction — ';
angleLabel.appendChild(angleValSpan);
const angleInput = document.createElement('input');
angleInput.type = 'range';
angleInput.className = 'form-control-range';
angleInput.min = '0'; angleInput.max = '360'; angleInput.step = '5';
angleInput.value = this.data.gradientAngle;
angleInput.addEventListener('input', () => {
this.data.gradientAngle = parseInt(angleInput.value);
angleValSpan.textContent = angleInput.value + '°';
updatePreview();
});
// Visual angle wheel indicator
const angleWheel = document.createElement('div');
angleWheel.style.cssText = 'width:38px;height:38px;border-radius:50%;border:2px solid #4e73df;display:flex;align-items:center;justify-content:center;margin-top:4px;position:relative;background:#eef2ff;';
const wheelArrow = document.createElement('div');
wheelArrow.style.cssText = 'width:2px;height:14px;background:#4e73df;border-radius:2px;transform-origin:bottom center;position:absolute;bottom:50%;left:calc(50% - 1px);';
const updateWheel = () => { wheelArrow.style.transform = `rotate(${this.data.gradientAngle}deg)`; };
updateWheel();
angleWheel.appendChild(wheelArrow);
angleCol.appendChild(angleLabel);
angleCol.appendChild(angleInput);
angleCol.appendChild(angleWheel);
gradRow2.appendChild(angleCol);
// -- Radial X/Y --
const radialXCol = document.createElement('div'); radialXCol.className = 'col-md-2 mb-2';
const radialXLabel = document.createElement('label');
radialXLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';
const radXSpan = document.createElement('span'); radXSpan.textContent = this.data.gradientRadialX + '%';
radialXLabel.innerHTML = 'Radial X — '; radialXLabel.appendChild(radXSpan);
const radialXInput = document.createElement('input');
radialXInput.type = 'range'; radialXInput.className = 'form-control-range';
radialXInput.min = '0'; radialXInput.max = '100'; radialXInput.step = '5';
radialXInput.value = this.data.gradientRadialX;
radialXInput.addEventListener('input', () => {
this.data.gradientRadialX = parseInt(radialXInput.value);
radXSpan.textContent = radialXInput.value + '%';
updatePreview();
});
radialXCol.appendChild(radialXLabel); radialXCol.appendChild(radialXInput);
const radialYCol = document.createElement('div'); radialYCol.className = 'col-md-2 mb-2';
const radialYLabel = document.createElement('label');
radialYLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';
const radYSpan = document.createElement('span'); radYSpan.textContent = this.data.gradientRadialY + '%';
radialYLabel.innerHTML = 'Radial Y — '; radialYLabel.appendChild(radYSpan);
const radialYInput = document.createElement('input');
radialYInput.type = 'range'; radialYInput.className = 'form-control-range';
radialYInput.min = '0'; radialYInput.max = '100'; radialYInput.step = '5';
radialYInput.value = this.data.gradientRadialY;
radialYInput.addEventListener('input', () => {
this.data.gradientRadialY = parseInt(radialYInput.value);
radYSpan.textContent = radialYInput.value + '%';
updatePreview();
});
radialYCol.appendChild(radialYLabel); radialYCol.appendChild(radialYInput);
gradRow2.appendChild(radialXCol);
gradRow2.appendChild(radialYCol);
// -- Colour pickers --
const color1Col = document.createElement('div'); color1Col.className = 'col-md-2 mb-2';
const color1Label = document.createElement('label');
color1Label.className = 'small font-weight-bold text-secondary mb-1 d-block';
color1Label.textContent = 'Colour 1';
const color1Input = document.createElement('input');
color1Input.type = 'color'; color1Input.className = 'form-control form-control-sm p-1';
color1Input.style.height = '34px';
color1Input.value = this.data.gradientColor1 || '#000000';
color1Input.addEventListener('input', () => {
this.data.gradientColor1 = color1Input.value;
updatePreview();
});
color1Col.appendChild(color1Label); color1Col.appendChild(color1Input);
const color2Col = document.createElement('div'); color2Col.className = 'col-md-2 mb-2';
const color2Label = document.createElement('label');
color2Label.className = 'small font-weight-bold text-secondary mb-1 d-block';
color2Label.textContent = 'Colour 2';
const color2Input = document.createElement('input');
color2Input.type = 'color'; color2Input.className = 'form-control form-control-sm p-1';
color2Input.style.height = '34px';
color2Input.value = this.data.gradientColor2 || '#1e3a5f';
color2Input.addEventListener('input', () => {
this.data.gradientColor2 = color2Input.value;
updatePreview();
});
color2Col.appendChild(color2Label); color2Col.appendChild(color2Input);
gradRow2.appendChild(color1Col);
gradRow2.appendChild(color2Col);
gradSection.appendChild(gradRow2);
// ── Advanced Settings (CSS Class, Element ID, Style, Attrs) ──────────────
const advSection = document.createElement('div');
advSection.style.cssText = 'background:#f8f9fc;border:1px solid #e3e6f0;border-radius:6px;padding:12px;margin-bottom:12px;';
const advHeader = document.createElement('div');
advHeader.className = 'small font-weight-bold text-secondary mb-2 cursor-pointer d-flex align-items-center justify-content-between';
advHeader.style.cursor = 'pointer';
advHeader.innerHTML = '<span><i class="fas fa-cog mr-1"></i> Advanced Attributes (CSS Class, Element ID, Style, Custom Attributes)</span><i class="fas fa-chevron-up ml-2 toggle-icon" style="font-size:10px;"></i>';
const advBody = document.createElement('div');
advBody.style.display = 'block'; // Expanded by default so fields are immediately visible
advHeader.addEventListener('click', (e) => {
e.preventDefault();
const isHidden = advBody.style.display === 'none';
advBody.style.display = isHidden ? 'block' : 'none';
const icon = advHeader.querySelector('.toggle-icon');
if (icon) {
icon.className = `fas fa-chevron-${isHidden ? 'up' : 'down'} ml-2 toggle-icon`;
}
});
advSection.appendChild(advHeader);
advSection.appendChild(advBody);
const advRow1 = document.createElement('div');
advRow1.className = 'form-row';
const cssCol = document.createElement('div'); cssCol.className = 'col-md-6 mb-2';
const idCol = document.createElement('div'); idCol.className = 'col-md-6 mb-2';
const cssLabel = document.createElement('label'); cssLabel.className = 'small font-weight-bold text-secondary mb-1'; cssLabel.textContent = 'CSS Class';
this.cssClassInput = document.createElement('input'); this.cssClassInput.type = 'text'; this.cssClassInput.className = 'form-control form-control-sm'; this.cssClassInput.value = this.data.cssClass || '';
cssCol.appendChild(cssLabel); cssCol.appendChild(this.cssClassInput);
const idLabel = document.createElement('label'); idLabel.className = 'small font-weight-bold text-secondary mb-1'; idLabel.textContent = 'Element ID';
this.elementIdInput = document.createElement('input'); this.elementIdInput.type = 'text'; this.elementIdInput.className = 'form-control form-control-sm'; this.elementIdInput.value = this.data.elementId || '';
idCol.appendChild(idLabel); idCol.appendChild(this.elementIdInput);
advRow1.appendChild(cssCol); advRow1.appendChild(idCol);
advBody.appendChild(advRow1);
const advRow2 = document.createElement('div');
advRow2.className = 'form-row';
const styleCol = document.createElement('div'); styleCol.className = 'col-md-6 mb-2';
const attrsCol = document.createElement('div'); attrsCol.className = 'col-md-6 mb-2';
const styleLabel = document.createElement('label'); styleLabel.className = 'small font-weight-bold text-secondary mb-1'; styleLabel.textContent = 'Custom Style';
this.customStyleInput = document.createElement('input'); this.customStyleInput.type = 'text'; this.customStyleInput.className = 'form-control form-control-sm'; this.customStyleInput.value = this.data.customStyle || '';
styleCol.appendChild(styleLabel); styleCol.appendChild(this.customStyleInput);
const attrsLabel = document.createElement('label'); attrsLabel.className = 'small font-weight-bold text-secondary mb-1'; attrsLabel.textContent = 'Custom Attributes';
this.customAttrsInput = document.createElement('input'); this.customAttrsInput.type = 'text'; this.customAttrsInput.className = 'form-control form-control-sm'; this.customAttrsInput.value = this.data.customAttrs || '';
attrsCol.appendChild(attrsLabel); attrsCol.appendChild(this.customAttrsInput);
advRow2.appendChild(styleCol); advRow2.appendChild(attrsCol);
advBody.appendChild(advRow2);
// Show/hide angle vs radial controls based on type
const updateDirectionVisibility = () => {
const t = gradTypeSelect.value;
const isLinear = ['linear', 'linear_both', 'duo_blue', 'duo_purple', 'duo_sunset', 'duo_ocean', 'duo_forest', 'duo_rose', 'custom_color'].includes(t);
const isRadial = ['radial', 'radial_both'].includes(t);
const showColors = ['linear', 'linear_both', 'radial', 'radial_both', 'custom_color'].includes(t);
angleCol.style.display = isLinear ? '' : 'none';
radialXCol.style.display = isRadial ? '' : 'none';
radialYCol.style.display = isRadial ? '' : 'none';
color1Col.style.display = showColors ? '' : 'none';
color2Col.style.display = showColors ? '' : 'none';
};
updateDirectionVisibility();
gradTypeSelect.addEventListener('change', () => {
this.data.gradientType = gradTypeSelect.value;
updateDirectionVisibility();
updatePreview();
});
angleInput.addEventListener('input', updateWheel);
// ── Live Preview Box ─────────────────────────────────────────────────────
const previewBox = document.createElement('div'); const previewBox = document.createElement('div');
previewBox.className = 'hero-banner-preview p-4 rounded text-white my-2'; previewBox.className = 'hero-banner-preview p-4 rounded text-white my-2';
previewBox.style.position = 'relative'; previewBox.style.cssText = 'position:relative;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;background-size:cover;background-position:center;overflow:hidden;';
previewBox.style.minHeight = this.data.height || '350px'; previewBox.style.minHeight = this.data.height || '350px';
previewBox.style.display = 'flex';
previewBox.style.flexDirection = 'column';
previewBox.style.justifyContent = 'center';
previewBox.style.alignItems = 'center';
previewBox.style.textAlign = 'center';
previewBox.style.backgroundSize = 'cover';
previewBox.style.backgroundPosition = 'center';
previewBox.style.overflow = 'hidden';
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.style.position = 'absolute'; overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:1;';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.right = '0';
overlay.style.bottom = '0';
overlay.style.background = '#000';
overlay.style.zIndex = '1';
previewBox.appendChild(overlay); previewBox.appendChild(overlay);
const contentBox = document.createElement('div'); const contentBox = document.createElement('div');
contentBox.style.position = 'relative'; contentBox.style.cssText = 'position:relative;z-index:2;';
contentBox.style.zIndex = '2';
previewBox.appendChild(contentBox); previewBox.appendChild(contentBox);
// ── updatePreview() ──────────────────────────────────────────────────────
const updatePreview = () => { const updatePreview = () => {
const bg = this.bgImageInput.querySelector('input').value.trim(); const bg = this.bgImageInput.querySelector('input').value.trim();
const title = this.titleInput.querySelector('input').value.trim() || 'Hero Banner Title'; const title = this.titleInput.querySelector('input').value.trim() || 'Hero Banner Title';
const sub = this.subtitleInput.querySelector('input').value.trim(); const sub = this.subtitleInput.querySelector('input').value.trim();
const btnT = this.btnTextInput.querySelector('input').value.trim(); const btnT = this.btnTextInput.querySelector('input').value.trim();
const bannerH = this.heightInput.querySelector('input').value.trim() || '350px'; const h = this.heightInput.querySelector('input').value.trim() || '350px';
previewBox.style.minHeight = bannerH; previewBox.style.minHeight = h;
previewBox.style.backgroundImage = bg ? 'url("' + bg + '")' : 'linear-gradient(135deg, #002554, #881C1C)'; previewBox.style.backgroundImage = bg ? `url("${bg}")` : 'linear-gradient(135deg, #002554, #881C1C)';
overlay.style.opacity = this.data.overlayOpacity || '0.4';
let html = '<h4 class="font-weight-bold mb-1 text-white">' + title + '</h4>'; // Build gradient
if (sub) html += '<p class="small mb-2 text-light">' + sub + '</p>'; const preset = SISHeroBannerTool.GRADIENT_PRESETS.find(p => p.id === this.data.gradientType)
if (btnT) html += '<span class="btn btn-sm btn-danger font-weight-bold px-3">' + btnT + '</span>'; || SISHeroBannerTool.GRADIENT_PRESETS[0];
const gradCSS = preset.build(
this.data.gradientColor1,
this.data.gradientColor2,
parseFloat(this.data.overlayOpacity),
this.data.gradientAngle,
this.data.gradientRadialX,
this.data.gradientRadialY
);
overlay.style.background = gradCSS;
const txtColor = (this.textColorInput ? this.textColorInput.querySelector('input[type="text"]').value.trim() : (this.data.textColor || '#ffffff')) || '#ffffff';
let html = `<h4 class="font-weight-bold mb-1" style="color: ${txtColor} !important;">${title}</h4>`;
if (sub) html += `<p class="small mb-2" style="color: ${txtColor} !important;">${sub}</p>`;
if (btnT) html += `<span class="btn btn-sm btn-danger font-weight-bold px-3">${btnT}</span>`;
contentBox.innerHTML = html; contentBox.innerHTML = html;
}; };
// Wire all text inputs to preview
[this.titleInput, this.subtitleInput, this.bgImageInput].forEach(wrapper => { [this.titleInput, this.subtitleInput, this.bgImageInput].forEach(wrapper => {
const input = wrapper.querySelector('input'); const input = wrapper.querySelector('input');
input.addEventListener('input', updatePreview); input.addEventListener('input', updatePreview);
if (this.readOnly) input.disabled = true; if (this.readOnly) input.disabled = true;
container.appendChild(wrapper); container.appendChild(wrapper);
}); });
[this.heightInput, this.btnTextInput, this.btnLinkInput].forEach(wrapper => { [this.heightInput, this.btnTextInput, this.btnLinkInput].forEach(wrapper => {
const input = wrapper.querySelector('input'); const input = wrapper.querySelector('input');
input.addEventListener('input', updatePreview); input.addEventListener('input', updatePreview);
if (this.readOnly) input.disabled = true; if (this.readOnly) input.disabled = true;
}); });
if (this.textColorInput) {
this.textColorInput.querySelectorAll('input').forEach(inp => {
inp.addEventListener('input', updatePreview);
});
}
container.appendChild(configRow); container.appendChild(configRow);
container.appendChild(gradSection);
container.appendChild(advSection);
container.appendChild(previewBox); container.appendChild(previewBox);
updatePreview(); updatePreview();
return container; return container;
} }
// ─── Helpers ─────────────────────────────────────────────────────────────────
_createInput(labelText, placeholder, value) { _createInput(labelText, placeholder, value) {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'form-group mb-2'; wrapper.className = 'form-group mb-2';
@@ -170,10 +460,50 @@ class SISHeroBannerTool {
return wrapper; return wrapper;
} }
_createImageInput(labelText, placeholder, value) { _createColorPickerInput(labelText, value) {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'form-group mb-2'; wrapper.className = 'form-group mb-2';
const lbl = document.createElement('label');
lbl.className = 'small font-weight-bold text-secondary mb-1 d-block';
lbl.innerText = labelText;
wrapper.appendChild(lbl);
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group input-group-sm';
const picker = document.createElement('input');
picker.type = 'color';
picker.className = 'form-control form-control-sm p-0 border-0';
picker.style.cssText = 'max-width: 36px; height: 31px; cursor: pointer; background: transparent;';
picker.value = value || '#ffffff';
const txtInp = document.createElement('input');
txtInp.type = 'text';
txtInp.className = 'form-control';
txtInp.placeholder = '#ffffff';
txtInp.value = value || '#ffffff';
picker.addEventListener('input', () => {
txtInp.value = picker.value;
this.data.textColor = picker.value;
txtInp.dispatchEvent(new Event('input', { bubbles: true }));
});
txtInp.addEventListener('input', () => {
if (/^#[0-9A-F]{6}$/i.test(txtInp.value.trim())) {
picker.value = txtInp.value.trim();
}
this.data.textColor = txtInp.value.trim();
});
inputGroup.appendChild(picker);
inputGroup.appendChild(txtInp);
wrapper.appendChild(inputGroup);
return wrapper;
}
_createMediaPickerInput(labelText, placeholder, value) {
const wrapper = document.createElement('div');
wrapper.className = 'form-group mb-2';
const lbl = document.createElement('label'); const lbl = document.createElement('label');
lbl.className = 'small font-weight-bold text-secondary mb-1 d-block'; lbl.className = 'small font-weight-bold text-secondary mb-1 d-block';
lbl.innerText = labelText; lbl.innerText = labelText;
@@ -187,39 +517,48 @@ class SISHeroBannerTool {
inp.className = 'form-control'; inp.className = 'form-control';
inp.placeholder = placeholder; inp.placeholder = placeholder;
inp.value = value || ''; inp.value = value || '';
if (this.readOnly) inp.disabled = true;
inputGroup.appendChild(inp); inputGroup.appendChild(inp);
if (!this.readOnly) {
const appendDiv = document.createElement('div'); const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append'; appendDiv.className = 'input-group-append';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-outline-secondary';
btn.title = 'Choose image from media library / advanced settings';
btn.innerHTML = '<i class="fas fa-folder-open"></i>';
btn.addEventListener('click', () => {
const onSelectCallback = (url, mediaObj, config) => {
let selectedUrl = '';
if (typeof url === 'string') {
selectedUrl = url;
} else if (url && url.url) {
selectedUrl = url.url;
} else if (mediaObj && mediaObj.url) {
selectedUrl = mediaObj.url;
}
const mediaBtn = document.createElement('button'); if (selectedUrl) {
mediaBtn.type = 'button';
mediaBtn.className = 'btn btn-outline-info';
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
mediaBtn.addEventListener('click', () => {
if (window.SISMediaPicker) {
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
inp.value = selectedUrl; inp.value = selectedUrl;
if (config) { this._bgImageConfig = config || mediaObj || null;
this._bgImageConfig = config; this.data.bgImage = selectedUrl;
}
// Dispatch input event to trigger preview update
inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('input', { bubbles: true }));
}); }
} else { };
alert('Media modal function openSISMediaModal is not available.');
if (window.SISMediaPicker && typeof window.SISMediaPicker.open === 'function') {
window.SISMediaPicker.open(onSelectCallback, inp.value, this._bgImageConfig || {});
} else if (window.openSISMediaModal) {
window.openSISMediaModal(onSelectCallback);
} }
}); });
appendDiv.appendChild(mediaBtn); appendDiv.appendChild(btn);
inputGroup.appendChild(appendDiv); inputGroup.appendChild(appendDiv);
}
wrapper.appendChild(inputGroup); wrapper.appendChild(inputGroup);
return wrapper; return wrapper;
} }
// ─── save() ───────────────────────────────────────────────────────────────────
save(blockContent) { save(blockContent) {
const stretchCheck = blockContent.querySelector('.custom-control-input'); const stretchCheck = blockContent.querySelector('.custom-control-input');
return { return {
@@ -235,8 +574,21 @@ class SISHeroBannerTool {
btnLink: this.btnLinkInput ? this.btnLinkInput.querySelector('input').value : this.data.btnLink, btnLink: this.btnLinkInput ? this.btnLinkInput.querySelector('input').value : this.data.btnLink,
height: this.heightInput ? this.heightInput.querySelector('input').value : this.data.height, height: this.heightInput ? this.heightInput.querySelector('input').value : this.data.height,
textAlign: this.data.textAlign || 'center', textAlign: this.data.textAlign || 'center',
overlayOpacity: this.data.overlayOpacity || '0.4', overlayOpacity: this.data.overlayOpacity || '0.5',
stretched: stretchCheck ? stretchCheck.checked : !!this.data.stretched stretched: stretchCheck ? stretchCheck.checked : !!this.data.stretched,
textColor: this.textColorInput ? this.textColorInput.querySelector('input[type="text"]').value.trim() : (this.data.textColor || '#ffffff'),
// Advanced element attributes (class, id, style, custom attributes)
cssClass: this.cssClassInput ? this.cssClassInput.value.trim() : (this.data.cssClass || ''),
elementId: this.elementIdInput ? this.elementIdInput.value.trim() : (this.data.elementId || ''),
customStyle: this.customStyleInput ? this.customStyleInput.value.trim() : (this.data.customStyle || ''),
customAttrs: this.customAttrsInput ? this.customAttrsInput.value.trim() : (this.data.customAttrs || ''),
// Gradient
gradientType: this.data.gradientType || 'dark_bottom',
gradientAngle: this.data.gradientAngle ?? 160,
gradientRadialX: this.data.gradientRadialX ?? 50,
gradientRadialY: this.data.gradientRadialY ?? 50,
gradientColor1: this.data.gradientColor1 || '#000000',
gradientColor2: this.data.gradientColor2 || '#1e3a5f',
}; };
} }
} }
@@ -40,6 +40,8 @@ class SISPostsTool {
itemsPerPage: parseInt(data && data.itemsPerPage) || parseInt(data && data.limit) || 12, itemsPerPage: parseInt(data && data.itemsPerPage) || parseInt(data && data.limit) || 12,
enablePagination: data && data.enablePagination !== undefined ? !!data.enablePagination : true, enablePagination: data && data.enablePagination !== undefined ? !!data.enablePagination : true,
enableTabs: data && data.enableTabs !== undefined ? !!data.enableTabs : true, enableTabs: data && data.enableTabs !== undefined ? !!data.enableTabs : true,
showViewMore: data && data.showViewMore !== undefined ? !!data.showViewMore : false,
viewMoreUrl: data && data.viewMoreUrl ? data.viewMoreUrl : '',
globalId: data && data.globalId ? data.globalId : '', globalId: data && data.globalId ? data.globalId : '',
globalClass: data && data.globalClass ? data.globalClass : 'sis-posts-block', globalClass: data && data.globalClass ? data.globalClass : 'sis-posts-block',
globalStyle: data && data.globalStyle ? data.globalStyle : '', globalStyle: data && data.globalStyle ? data.globalStyle : '',
@@ -115,7 +117,7 @@ class SISPostsTool {
// 3. Limit (Number of posts) // 3. Limit (Number of posts)
const limitDiv = document.createElement('div'); const limitDiv = document.createElement('div');
limitDiv.className = 'col-md-2 mb-2'; limitDiv.className = 'col-md-2 mb-2';
limitDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Limit Posts (12 = 4 rows)</label>'; limitDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Limit Posts</label>';
const limitInput = document.createElement('input'); const limitInput = document.createElement('input');
limitInput.type = 'number'; limitInput.type = 'number';
limitInput.className = 'form-control form-control-sm'; limitInput.className = 'form-control form-control-sm';
@@ -161,35 +163,32 @@ class SISPostsTool {
const formRow2 = document.createElement('div'); const formRow2 = document.createElement('div');
formRow2.className = 'form-row mb-3 pb-2 border-bottom'; formRow2.className = 'form-row mb-3 pb-2 border-bottom';
// 1. Category Filter Multi-Select Dropdown // 1. Category Filter Multi-Select (Select2)
const catDiv = document.createElement('div'); const catDiv = document.createElement('div');
catDiv.className = 'col-md-3 mb-2'; catDiv.className = 'col-md-3 mb-2';
catDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-folder"></i> Category Filter (Multi-Choice = Tabs)</label>'; catDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-folder"></i> Category Filter</label>';
const catSelect = document.createElement('select'); const catSelect = document.createElement('select');
catSelect.className = 'form-control form-control-sm'; catSelect.className = 'form-control form-control-sm sis-select2-multi';
catSelect.multiple = true; catSelect.multiple = true;
catSelect.style.height = '85px'; catSelect.setAttribute('data-placeholder', '-- All Categories --');
const populateCatSelect = () => { const populateCatSelect = () => {
// Preserve current Select2 selection before repopulating
const currentVals = (this.data.categoryFilter || '').split(',').map(s => s.trim()).filter(Boolean);
catSelect.innerHTML = ''; catSelect.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = 'ALL';
allOpt.textContent = '-- All Categories --';
if (!this.data.categoryFilter || this.data.categoryFilter === 'ALL') allOpt.selected = true;
catSelect.appendChild(allOpt);
const list = window.SISSystemCategories || []; const list = window.SISSystemCategories || [];
const selectedVals = (this.data.categoryFilter || '').split(',').map(s => s.trim()).filter(Boolean);
list.forEach(c => { list.forEach(c => {
const val = (typeof c === 'string') ? c : (c.name || c.title || ''); const val = (typeof c === 'string') ? c : (c.name || c.title || '');
if (!val) return; if (!val) return;
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = val; opt.value = val;
opt.textContent = val; opt.textContent = val;
if (selectedVals.includes(val)) opt.selected = true; if (currentVals.includes(val)) opt.selected = true;
catSelect.appendChild(opt); catSelect.appendChild(opt);
}); });
if (typeof $ !== 'undefined' && $(catSelect).data('select2')) {
$(catSelect).trigger('change.select2');
}
}; };
populateCatSelect(); populateCatSelect();
@@ -200,44 +199,40 @@ class SISPostsTool {
if (this.readOnly) catSelect.disabled = true; if (this.readOnly) catSelect.disabled = true;
catSelect.addEventListener('change', () => { catSelect.addEventListener('change', () => {
const selected = Array.from(catSelect.selectedOptions).map(o => o.value); const selected = Array.from(catSelect.selectedOptions).map(o => o.value);
if (selected.includes('ALL') || selected.includes('')) { this.data.categoryFilter = selected.filter(v => v !== 'ALL' && v !== '').join(',');
this.data.categoryFilter = ''; updateViewMoreVisibility();
} else {
this.data.categoryFilter = selected.filter(v => v !== 'ALL').join(',');
}
}); });
catDiv.appendChild(catSelect); catDiv.appendChild(catSelect);
formRow2.appendChild(catDiv); formRow2.appendChild(catDiv);
// 2. Tag Filter Multi-Select Dropdown // Schedule Select2 init after DOM insertion
this._scheduleSelect2(catSelect, '-- All Categories --');
// 2. Tag Filter Multi-Select (Select2)
const tagDiv = document.createElement('div'); const tagDiv = document.createElement('div');
tagDiv.className = 'col-md-3 mb-2'; tagDiv.className = 'col-md-3 mb-2';
tagDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-tags"></i> Tag Filter (Multi-Choice = Tabs)</label>'; tagDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-tags"></i> Tag Filter</label>';
const tagSelect = document.createElement('select'); const tagSelect = document.createElement('select');
tagSelect.className = 'form-control form-control-sm'; tagSelect.className = 'form-control form-control-sm sis-select2-multi';
tagSelect.multiple = true; tagSelect.multiple = true;
tagSelect.style.height = '85px'; tagSelect.setAttribute('data-placeholder', '-- All Tags --');
const populateTagSelect = () => { const populateTagSelect = () => {
const currentVals = (this.data.tagFilter || '').split(',').map(s => s.trim()).filter(Boolean);
tagSelect.innerHTML = ''; tagSelect.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = 'ALL';
allOpt.textContent = '-- All Tags --';
if (!this.data.tagFilter || this.data.tagFilter === 'ALL') allOpt.selected = true;
tagSelect.appendChild(allOpt);
const list = window.SISSystemTags || []; const list = window.SISSystemTags || [];
const selectedVals = (this.data.tagFilter || '').split(',').map(s => s.trim()).filter(Boolean);
list.forEach(t => { list.forEach(t => {
const val = (typeof t === 'string') ? t : (t.name || t.title || ''); const val = (typeof t === 'string') ? t : (t.name || t.title || '');
if (!val) return; if (!val) return;
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = val; opt.value = val;
opt.textContent = val; opt.textContent = val;
if (selectedVals.includes(val)) opt.selected = true; if (currentVals.includes(val)) opt.selected = true;
tagSelect.appendChild(opt); tagSelect.appendChild(opt);
}); });
if (typeof $ !== 'undefined' && $(tagSelect).data('select2')) {
$(tagSelect).trigger('change.select2');
}
}; };
populateTagSelect(); populateTagSelect();
@@ -248,15 +243,49 @@ class SISPostsTool {
if (this.readOnly) tagSelect.disabled = true; if (this.readOnly) tagSelect.disabled = true;
tagSelect.addEventListener('change', () => { tagSelect.addEventListener('change', () => {
const selected = Array.from(tagSelect.selectedOptions).map(o => o.value); const selected = Array.from(tagSelect.selectedOptions).map(o => o.value);
if (selected.includes('ALL') || selected.includes('')) { this.data.tagFilter = selected.filter(v => v !== 'ALL' && v !== '').join(',');
this.data.tagFilter = ''; updateViewMoreVisibility();
} else {
this.data.tagFilter = selected.filter(v => v !== 'ALL').join(',');
}
}); });
tagDiv.appendChild(tagSelect); tagDiv.appendChild(tagSelect);
formRow2.appendChild(tagDiv); formRow2.appendChild(tagDiv);
// Schedule Select2 init after DOM insertion
this._scheduleSelect2(tagSelect, '-- All Tags --');
// "Xem Thêm" URL row — visible only when exactly 1 category OR 1 tag is selected
const viewMoreRow = document.createElement('div');
viewMoreRow.className = 'form-row mb-2';
const viewMoreDiv = document.createElement('div');
viewMoreDiv.className = 'col-md-12 mb-2';
const viewMoreLabel = document.createElement('label');
viewMoreLabel.className = 'small font-weight-bold text-secondary mb-1';
viewMoreLabel.innerHTML = '<i class="fas fa-external-link-alt"></i> URL nút "Xem Thêm"';
const viewMoreInput = document.createElement('input');
viewMoreInput.type = 'text';
viewMoreInput.className = 'form-control form-control-sm';
viewMoreInput.placeholder = 'e.g. /category/tin-tuc or https://sisvietnam.vn/...';
viewMoreInput.value = this.data.viewMoreUrl || '';
if (this.readOnly) viewMoreInput.disabled = true;
viewMoreInput.addEventListener('input', (e) => this.data.viewMoreUrl = e.target.value.trim());
this.viewMoreInput = viewMoreInput;
viewMoreDiv.appendChild(viewMoreLabel);
viewMoreDiv.appendChild(viewMoreInput);
viewMoreRow.appendChild(viewMoreDiv);
formRow2.appendChild(viewMoreRow);
// Helper: count unique non-ALL selections across both selects
const updateViewMoreVisibility = () => {
const cats = (this.data.categoryFilter || '').split(',').map(s => s.trim()).filter(Boolean);
const tags = (this.data.tagFilter || '').split(',').map(s => s.trim()).filter(Boolean);
const isSingle = (cats.length === 1 && tags.length === 0) || (tags.length === 1 && cats.length === 0);
viewMoreRow.style.display = isSingle ? '' : 'none';
};
updateViewMoreVisibility();
// 3. Location Filter Attribute // 3. Location Filter Attribute
const locDiv = document.createElement('div'); const locDiv = document.createElement('div');
locDiv.className = 'col-md-3 mb-2'; locDiv.className = 'col-md-3 mb-2';
@@ -364,7 +393,8 @@ class SISPostsTool {
{ id: 'showDt', key: 'showDate', label: 'Published / Event Date' }, { id: 'showDt', key: 'showDate', label: 'Published / Event Date' },
{ id: 'showLoc', key: 'showLocation', label: 'Location Tag' }, { id: 'showLoc', key: 'showLocation', label: 'Location Tag' },
{ id: 'enableTabs', key: 'enableTabs', label: 'Filter Tabs' }, { id: 'enableTabs', key: 'enableTabs', label: 'Filter Tabs' },
{ id: 'enablePag', key: 'enablePagination', label: 'Pagination Controls' } { id: 'enablePag', key: 'enablePagination', label: 'Pagination Controls' },
{ id: 'showViewMore', key: 'showViewMore', label: '"Xem Thêm" Link' }
]; ];
toggles.forEach(t => { toggles.forEach(t => {
@@ -390,9 +420,76 @@ class SISPostsTool {
this.wrapper.appendChild(toggleRow); this.wrapper.appendChild(toggleRow);
// "Xem Thêm" URL input — always present, visibility driven by showViewMore checkbox
const viewMoreUrlRow = document.createElement('div');
viewMoreUrlRow.className = 'form-row mt-1 mb-2 px-1';
viewMoreUrlRow.style.display = this.data.showViewMore ? '' : 'none';
const viewMoreUrlCol = document.createElement('div');
viewMoreUrlCol.className = 'col-md-12';
const viewMoreUrlLabel = document.createElement('label');
viewMoreUrlLabel.className = 'small font-weight-bold text-secondary mb-1';
viewMoreUrlLabel.innerHTML = '<i class="fas fa-external-link-alt"></i> URL nút "Xem Thêm"';
const viewMoreUrlInput = document.createElement('input');
viewMoreUrlInput.type = 'text';
viewMoreUrlInput.className = 'form-control form-control-sm';
viewMoreUrlInput.placeholder = 'e.g. /posts/category/tin-tuc or https://sisvietnam.vn/...';
viewMoreUrlInput.value = this.data.viewMoreUrl || '';
if (this.readOnly) viewMoreUrlInput.disabled = true;
viewMoreUrlInput.addEventListener('input', (e) => this.data.viewMoreUrl = e.target.value.trim());
this.viewMoreInput = viewMoreUrlInput;
viewMoreUrlCol.appendChild(viewMoreUrlLabel);
viewMoreUrlCol.appendChild(viewMoreUrlInput);
viewMoreUrlRow.appendChild(viewMoreUrlCol);
this.wrapper.appendChild(viewMoreUrlRow);
// Wire the showViewMore checkbox to show/hide the URL row
// Find the showViewMore checkbox after all toggles have been rendered
const showViewMoreCheck = toggleRow.querySelector('input[id^="posts-toggle-showViewMore"]');
if (showViewMoreCheck) {
showViewMoreCheck.addEventListener('change', (e) => {
this.data.showViewMore = e.target.checked;
viewMoreUrlRow.style.display = e.target.checked ? '' : 'none';
});
}
return this.wrapper; return this.wrapper;
} }
/**
* Initialize Select2 on a <select multiple> element once it is attached to the DOM.
* Editor.js inserts the wrapper returned by render() into the DOM *after* render() returns,
* so we poll with requestAnimationFrame until the element is connected, then call Select2.
*
* @param {HTMLSelectElement} selectEl - The select element to enhance.
* @param {string} placeholder - Placeholder text shown when nothing is selected.
*/
_scheduleSelect2(selectEl, placeholder) {
const tryInit = () => {
if (!selectEl.isConnected) {
requestAnimationFrame(tryInit);
return;
}
if (typeof $ === 'undefined' || typeof $.fn.select2 === 'undefined') return;
$(selectEl).select2({
theme: 'bootstrap',
placeholder: placeholder,
allowClear: true,
width: '100%'
});
// Select2 swallows native 'change' events re-fire so our existing listeners work
$(selectEl).on('select2:select select2:unselect', function () {
selectEl.dispatchEvent(new Event('change', { bubbles: true }));
});
};
requestAnimationFrame(tryInit);
}
save() { save() {
return { return {
category: this.data.category || 'ALL', category: this.data.category || 'ALL',
@@ -402,7 +499,7 @@ class SISPostsTool {
statusFilter: this.data.statusFilter || 'PUBLISHED', statusFilter: this.data.statusFilter || 'PUBLISHED',
displayStyle: this.data.displayStyle || 'grid', displayStyle: this.data.displayStyle || 'grid',
cols: parseInt(this.data.cols) || 3, cols: parseInt(this.data.cols) || 3,
limit: parseInt(this.data.limit) || 6, limit: parseInt(this.data.limit) || 12,
orderBy: this.data.orderBy || 'createdDate_desc', orderBy: this.data.orderBy || 'createdDate_desc',
locationFilter: this.data.locationFilter || '', locationFilter: this.data.locationFilter || '',
postIds: this.data.postIds || '', postIds: this.data.postIds || '',
@@ -410,9 +507,11 @@ class SISPostsTool {
showExcerpt: !!this.data.showExcerpt, showExcerpt: !!this.data.showExcerpt,
showDate: !!this.data.showDate, showDate: !!this.data.showDate,
showLocation: !!this.data.showLocation, showLocation: !!this.data.showLocation,
itemsPerPage: parseInt(this.data.itemsPerPage) || parseInt(this.data.limit) || 9, itemsPerPage: parseInt(this.data.itemsPerPage) || parseInt(this.data.limit) || 12,
enablePagination: !!this.data.enablePagination, enablePagination: !!this.data.enablePagination,
enableTabs: !!this.data.enableTabs, enableTabs: !!this.data.enableTabs,
showViewMore: !!this.data.showViewMore,
viewMoreUrl: this.viewMoreInput ? this.viewMoreInput.value.trim() : (this.data.viewMoreUrl || ''),
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''), globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || 'sis-posts-block'), globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || 'sis-posts-block'),
globalStyle: this.globalStyleInput ? this.globalStyleInput.value.trim() : (this.data.globalStyle || ''), globalStyle: this.globalStyleInput ? this.globalStyleInput.value.trim() : (this.data.globalStyle || ''),
@@ -9,21 +9,28 @@
}; };
} }
constructor({ data, api, readOnly }) { constructor({ data, api, readOnly, block }) {
this.api = api; this.api = api;
this.blockAPI = block; // Store Block API for reliable index lookup
this.readOnly = readOnly; this.readOnly = readOnly;
this.data = { this.data = {
html: data.html || '' html: data.html || '',
cssClass: data.cssClass || '',
elementId: data.elementId || '',
customStyle: data.customStyle || '',
customAttrs: data.customAttrs || ''
}; };
this.editorId = 'tinymce-editor-' + Math.random().toString(36).substring(7); this.editorId = 'tinymce-editor-' + Math.random().toString(36).substring(7);
this.editorInstance = null; this.editorInstance = null;
} }
render() { render() {
const container = document.createElement('div');
container.className = 'sis-tinymce-container d-flex flex-column h-100';
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'sis-tinymce-block-wrapper border rounded p-2 bg-white d-flex flex-column h-100 flex-fill'; wrapper.className = 'sis-tinymce-block-wrapper border rounded p-2 bg-white d-flex flex-column flex-fill mb-2';
wrapper.style.minHeight = '280px'; wrapper.style.minHeight = '280px';
wrapper.style.height = '100%';
wrapper.style.flex = '1 1 auto'; wrapper.style.flex = '1 1 auto';
const textarea = document.createElement('textarea'); const textarea = document.createElement('textarea');
@@ -35,9 +42,70 @@
wrapper.appendChild(textarea); wrapper.appendChild(textarea);
this.textareaElement = textarea; this.textareaElement = textarea;
container.appendChild(wrapper);
// ── Advanced Settings (CSS Class, Element ID, Style, Attrs) ──────────────
const advSection = document.createElement('div');
advSection.style.cssText = 'background:#f8f9fc;border:1px solid #e3e6f0;border-radius:6px;padding:12px;margin-bottom:12px;';
const advHeader = document.createElement('div');
advHeader.className = 'small font-weight-bold text-secondary mb-2 cursor-pointer d-flex align-items-center justify-content-between';
advHeader.style.cursor = 'pointer';
advHeader.innerHTML = '<span><i class="fas fa-cog mr-1"></i> Advanced Attributes (CSS Class, Element ID, Style, Custom Attributes)</span><i class="fas fa-chevron-up ml-2 toggle-icon" style="font-size:10px;"></i>';
const advBody = document.createElement('div');
advBody.style.display = 'block'; // Expanded by default so fields are immediately visible
advHeader.addEventListener('click', (e) => {
e.preventDefault();
const isHidden = advBody.style.display === 'none';
advBody.style.display = isHidden ? 'block' : 'none';
const icon = advHeader.querySelector('.toggle-icon');
if (icon) {
icon.className = `fas fa-chevron-${isHidden ? 'up' : 'down'} ml-2 toggle-icon`;
}
});
advSection.appendChild(advHeader);
advSection.appendChild(advBody);
const advRow1 = document.createElement('div');
advRow1.className = 'form-row';
const cssCol = document.createElement('div'); cssCol.className = 'col-md-6 mb-2';
const idCol = document.createElement('div'); idCol.className = 'col-md-6 mb-2';
const cssLabel = document.createElement('label'); cssLabel.className = 'small font-weight-bold text-secondary mb-1'; cssLabel.textContent = 'CSS Class';
this.cssClassInput = document.createElement('input'); this.cssClassInput.type = 'text'; this.cssClassInput.className = 'form-control form-control-sm'; this.cssClassInput.value = this.data.cssClass || '';
cssCol.appendChild(cssLabel); cssCol.appendChild(this.cssClassInput);
const idLabel = document.createElement('label'); idLabel.className = 'small font-weight-bold text-secondary mb-1'; idLabel.textContent = 'Element ID';
this.elementIdInput = document.createElement('input'); this.elementIdInput.type = 'text'; this.elementIdInput.className = 'form-control form-control-sm'; this.elementIdInput.value = this.data.elementId || '';
idCol.appendChild(idLabel); idCol.appendChild(this.elementIdInput);
advRow1.appendChild(cssCol); advRow1.appendChild(idCol);
advBody.appendChild(advRow1);
const advRow2 = document.createElement('div');
advRow2.className = 'form-row';
const styleCol = document.createElement('div'); styleCol.className = 'col-md-6 mb-2';
const attrsCol = document.createElement('div'); attrsCol.className = 'col-md-6 mb-2';
const styleLabel = document.createElement('label'); styleLabel.className = 'small font-weight-bold text-secondary mb-1'; styleLabel.textContent = 'Custom Style';
this.customStyleInput = document.createElement('input'); this.customStyleInput.type = 'text'; this.customStyleInput.className = 'form-control form-control-sm'; this.customStyleInput.value = this.data.customStyle || '';
styleCol.appendChild(styleLabel); styleCol.appendChild(this.customStyleInput);
const attrsLabel = document.createElement('label'); attrsLabel.className = 'small font-weight-bold text-secondary mb-1'; attrsLabel.textContent = 'Custom Attributes';
this.customAttrsInput = document.createElement('input'); this.customAttrsInput.type = 'text'; this.customAttrsInput.className = 'form-control form-control-sm'; this.customAttrsInput.value = this.data.customAttrs || '';
attrsCol.appendChild(attrsLabel); attrsCol.appendChild(this.customAttrsInput);
advRow2.appendChild(styleCol); advRow2.appendChild(attrsCol);
advBody.appendChild(advRow2);
container.appendChild(advSection);
this._initTinyMCE(); this._initTinyMCE();
return wrapper; return container;
} }
_initTinyMCE() { _initTinyMCE() {
@@ -96,7 +164,34 @@
menubar: false, menubar: false,
readonly: !!self.readOnly, readonly: !!self.readOnly,
plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount', 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', toolbar: 'undo redo | blocks | bold italic forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image sismedia | code removeformat',
valid_elements: '*[*]',
extended_valid_elements: '*[*]',
valid_children: '+body[style|script|iframe],+div[style|script|iframe]',
verify_html: false,
allow_script_urls: true,
allow_html_in_named_anchor: true,
image_title: true,
file_picker_types: 'image media file',
file_picker_callback: function(callback, value, meta) {
self._loadSISMediaPicker(function() {
if (window.SISMediaPicker) {
window.SISMediaPicker.open(function(url, mediaObj, config) {
if (url) {
var info = {
alt: (config && config.alt) ? config.alt : '',
title: (mediaObj && (mediaObj.originalFilename || mediaObj.name)) ? (mediaObj.originalFilename || mediaObj.name) : ''
};
if (config) {
if (config.width) info.width = config.width;
if (config.height) info.height = config.height;
}
callback(url, info);
}
});
}
});
},
image_dimensions: true, image_dimensions: true,
object_resizing: 'img', 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; }', 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; }',
@@ -114,6 +209,74 @@
}, },
setup: function(editor) { setup: function(editor) {
self.editorInstance = editor; self.editorInstance = editor;
// Intercept mceCodeEditor command to open Monaco Editor instead of default TinyMCE modal
editor.on('BeforeExecCommand', function(e) {
if (e && e.command === 'mceCodeEditor') {
e.preventDefault();
e.stopImmediatePropagation();
self._openMonacoCodeModal(editor);
}
});
editor.on('init', function() {
editor.addCommand('mceCodeEditor', function() {
self._openMonacoCodeModal(editor);
});
editor.ui.registry.addButton('code', {
icon: 'sourcecode',
tooltip: 'Xem / Sửa Mã Nguồn (Monaco Editor)',
onAction: function() {
self._openMonacoCodeModal(editor);
}
});
editor.ui.registry.addMenuItem('code', {
icon: 'sourcecode',
text: 'Mã nguồn HTML (Monaco Editor)',
onAction: function() {
self._openMonacoCodeModal(editor);
}
});
});
// Register SIS Media Library toolbar button
editor.ui.registry.addButton('sismedia', {
icon: 'image',
tooltip: 'Chèn media từ Thư viện SIS (Media Library)',
onAction: function() {
self._loadSISMediaPicker(function() {
if (window.SISMediaPicker) {
window.SISMediaPicker.open(function(url, mediaObj, config) {
if (!url) return;
var fileType = (mediaObj && mediaObj.fileType) ? String(mediaObj.fileType).toUpperCase() : '';
var ext = url.split('?')[0].split('.').pop().toLowerCase();
var isVideo = fileType === 'VIDEO' || ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'avi', 'mkv'].indexOf(ext) !== -1;
var isAudio = fileType === 'AUDIO' || ['mp3', 'wav', 'ogg', 'aac', 'm4a', 'flac'].indexOf(ext) !== -1;
var attrStr = '';
if (config) {
if (config.alt) attrStr += ' alt="' + editor.dom.encode(config.alt) + '"';
if (config.id || config.elementId) attrStr += ' id="' + editor.dom.encode(config.id || config.elementId) + '"';
if (config.cssClass || config.class) attrStr += ' class="' + editor.dom.encode(config.cssClass || config.class) + '"';
if (config.style) attrStr += ' style="' + editor.dom.encode(config.style) + '"';
if (config.customAttributes) attrStr += ' ' + config.customAttributes;
}
if (isVideo) {
editor.insertContent('<video controls width="100%" src="' + url + '"' + attrStr + '></video>');
} else if (isAudio) {
editor.insertContent('<audio controls src="' + url + '"' + attrStr + '></audio>');
} else {
editor.insertContent('<img src="' + url + '"' + attrStr + ' />');
}
});
}
});
}
});
editor.on('init', function() { editor.on('init', function() {
const container = editor.getContainer(); const container = editor.getContainer();
if (container) { if (container) {
@@ -140,6 +303,63 @@
editor.on('change keyup undo redo', function() { editor.on('change keyup undo redo', function() {
self.data.html = editor.getContent(); self.data.html = editor.getContent();
}); });
// Use Editor.js API to set active block and move toolbar
const activateBlock = function() {
if (!self.api || !self.api.blocks || !self.textareaElement) return;
try {
const container = self.textareaElement.closest('.ce-block');
if (!container) return;
let myIndex = -1;
// Best way: use public getBlockByElement to trace from the actual DOM element
if (typeof self.api.blocks.getBlockByElement === 'function') {
const blockApiObj = self.api.blocks.getBlockByElement(self.textareaElement);
if (blockApiObj && blockApiObj.id) {
myIndex = self.api.blocks.getBlockIndex(blockApiObj.id);
}
} else if (self.blockAPI && self.blockAPI.id) {
// Fallback to constructor block API
myIndex = self.api.blocks.getBlockIndex(self.blockAPI.id);
}
if (myIndex !== -1) {
const currentIdx = self.api.blocks.getCurrentBlockIndex();
if (currentIdx !== myIndex) {
// Temporarily disable only text inputs (not textarea) so Editor.js doesn't steal focus
const inputs = container.querySelectorAll('.sis-tinymce-container input');
const disabledStates = [];
inputs.forEach(input => {
disabledStates.push(input.disabled);
input.disabled = true;
});
// Set active block (moves the toolbar)
self.api.caret.setToBlock(myIndex, 'default');
// Restore inputs
inputs.forEach((input, idx) => {
input.disabled = disabledStates[idx];
});
// Refocus TinyMCE
setTimeout(() => {
if (editor && typeof editor.focus === 'function') {
editor.focus();
}
}, 10);
}
}
} catch (e) {
console.warn("SIS TinyMCE: Failed to set active block", e);
}
};
editor.on('focusin focus click keyup', function() {
activateBlock();
});
} }
}); });
}; };
@@ -147,12 +367,264 @@
checkAndInit(); checkAndInit();
} }
save(blockContent) { _loadSISMediaPicker(callback) {
if (this.editorInstance) { if (window.SISMediaPicker) {
this.data.html = this.editorInstance.getContent(); callback();
return;
} }
window.SISMediaPickerLoading = window.SISMediaPickerLoading || {
loaded: false,
callbacks: []
};
if (window.SISMediaPickerLoading.loaded) {
callback();
return;
}
window.SISMediaPickerLoading.callbacks.push(callback);
if (window.SISMediaPickerLoading.callbacks.length === 1) {
const script = document.createElement('script');
script.src = '/js/manage/sis-media-picker.js';
script.onload = () => {
window.SISMediaPickerLoading.loaded = true;
while (window.SISMediaPickerLoading.callbacks.length > 0) {
const cb = window.SISMediaPickerLoading.callbacks.shift();
try { cb(); } catch (e) { console.error(e); }
}
};
script.onerror = () => {
console.error('[SIS TinyMCE Tool] Failed to load /js/manage/sis-media-picker.js');
};
document.head.appendChild(script);
}
}
_loadMonacoEditor(callback) {
if (window.monaco && window.monaco.editor) {
callback();
return;
}
window.SISMonacoLoading = window.SISMonacoLoading || {
loaded: false,
callbacks: []
};
if (window.SISMonacoLoading.loaded && window.monaco && window.monaco.editor) {
callback();
return;
}
window.SISMonacoLoading.callbacks.push(callback);
if (window.SISMonacoLoading.callbacks.length === 1) {
const loaderScript = document.createElement('script');
loaderScript.src = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js';
loaderScript.onload = () => {
if (window.require) {
window.require.config({
paths: {
'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs'
}
});
window.require(['vs/editor/editor.main'], () => {
window.SISMonacoLoading.loaded = true;
while (window.SISMonacoLoading.callbacks.length > 0) {
const cb = window.SISMonacoLoading.callbacks.shift();
try { cb(); } catch (e) { console.error(e); }
}
});
} else {
console.error('[Monaco Editor] Loader script loaded but window.require is undefined.');
}
};
loaderScript.onerror = () => {
console.error('[Monaco Editor] Failed to load Monaco Editor loader script.');
};
document.head.appendChild(loaderScript);
}
}
_openMonacoCodeModal(editor) {
const self = this;
const currentHtml = editor.getContent({ format: 'raw' });
// Create backdrop
const backdrop = document.createElement('div');
backdrop.className = 'sis-monaco-modal-backdrop';
backdrop.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(15,23,42,0.75);backdrop-filter:blur(4px);z-index:999999;display:flex;align-items:center;justify-content:center;padding:20px;';
// Create modal window
const modal = document.createElement('div');
modal.className = 'sis-monaco-modal-container';
modal.style.cssText = 'background:#1e1e1e;color:#d4d4d4;width:92vw;max-width:1250px;height:88vh;border-radius:10px;box-shadow:0 25px 50px -12px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.1);display:flex;flex-direction:column;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;';
// Header
const header = document.createElement('div');
header.style.cssText = 'padding:12px 20px;background:#252526;border-bottom:1px solid #333333;display:flex;align-items:center;justify-content:space-between;user-select:none;';
header.innerHTML = `
<div style="font-weight:600;font-size:15px;color:#ffffff;display:flex;align-items:center;gap:10px;">
<i class="fas fa-code" style="color:#007acc;font-size:16px;"></i>
<span>Mã nguồn HTML (Monaco Code Editor)</span>
</div>
<button type="button" class="monaco-close-x-btn" title="Đóng" style="background:none;border:none;color:#aaaaaa;font-size:22px;cursor:pointer;line-height:1;padding:0 4px;transition:color 0.2s;">&times;</button>
`;
// Body (Monaco host)
const body = document.createElement('div');
body.style.cssText = 'flex:1 1 auto;width:100%;height:100%;position:relative;background:#1e1e1e;overflow:hidden;';
const loadingSpinner = document.createElement('div');
loadingSpinner.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#9cdcfe;font-size:15px;display:flex;align-items:center;gap:10px;z-index:2;';
loadingSpinner.innerHTML = '<i class="fas fa-spinner fa-spin" style="font-size:18px;"></i> Đang tải Monaco Editor...';
body.appendChild(loadingSpinner);
// Footer
const footer = document.createElement('div');
footer.style.cssText = 'padding:10px 20px;background:#252526;border-top:1px solid #333333;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;';
footer.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;">
<button type="button" class="btn btn-sm monaco-format-btn" style="background:#2d2d2d;color:#cccccc;border:1px solid #454545;border-radius:4px;padding:4px 10px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;"><i class="fas fa-magic" style="color:#ce9178;"></i> Định dạng mã (Format)</button>
<button type="button" class="btn btn-sm monaco-wrap-btn" style="background:#2d2d2d;color:#cccccc;border:1px solid #454545;border-radius:4px;padding:4px 10px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;"><i class="fas fa-align-left" style="color:#9cdcfe;"></i> Word Wrap: On</button>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<button type="button" class="btn btn-sm monaco-cancel-btn" style="background:#3a3d41;color:#ffffff;border:none;border-radius:4px;padding:6px 14px;font-size:13px;cursor:pointer;">Hủy bỏ</button>
<button type="button" class="btn btn-sm monaco-save-btn" style="background:#0e639c;color:#ffffff;border:none;border-radius:4px;padding:6px 16px;font-size:13px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px;"><i class="fas fa-check"></i> Lưu thay đổi (Ctrl+S)</button>
</div>
`;
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
let monacoInstance = null;
let isWordWrap = true;
const closeModal = () => {
document.removeEventListener('keydown', onKeyDown);
if (monacoInstance) {
try { monacoInstance.dispose(); } catch (e) {}
monacoInstance = null;
}
self.activeMonacoInstance = null;
backdrop.remove();
};
const saveChanges = () => {
if (monacoInstance) {
const updatedCode = monacoInstance.getValue();
self.data.html = updatedCode;
if (self.textareaElement) {
self.textareaElement.value = updatedCode;
}
if (editor) {
try {
editor.setContent(updatedCode, { format: 'raw' });
editor.save();
editor.dispatch('change');
} catch (err) {
console.warn('[SIS TinyMCE] Error setting TinyMCE content from Monaco:', err);
}
}
if (self.api && self.api.events) {
try {
self.api.events.emit('change');
} catch (e) {}
}
}
closeModal();
};
const onKeyDown = (e) => {
if (e.key === 'Escape') {
closeModal();
}
};
document.addEventListener('keydown', onKeyDown);
// Bind Header Close button
const closeXBtn = header.querySelector('.monaco-close-x-btn');
closeXBtn.addEventListener('click', closeModal);
closeXBtn.addEventListener('mouseenter', () => { closeXBtn.style.color = '#ffffff'; });
closeXBtn.addEventListener('mouseleave', () => { closeXBtn.style.color = '#aaaaaa'; });
// Bind Footer Buttons
footer.querySelector('.monaco-cancel-btn').addEventListener('click', closeModal);
footer.querySelector('.monaco-save-btn').addEventListener('click', saveChanges);
const wrapBtn = footer.querySelector('.monaco-wrap-btn');
// Load Monaco
self._loadMonacoEditor(() => {
if (!document.body.contains(backdrop)) return; // modal was closed before loaded
if (loadingSpinner.parentNode) {
loadingSpinner.parentNode.removeChild(loadingSpinner);
}
monacoInstance = monaco.editor.create(body, {
value: currentHtml,
language: 'html',
theme: 'vs-dark',
automaticLayout: true,
minimap: { enabled: true },
wordWrap: 'on',
scrollBeyondLastLine: false,
fontSize: 13,
lineNumbers: 'on',
renderWhitespace: 'selection',
tabSize: 2,
folding: true,
formatOnPaste: true
});
self.activeMonacoInstance = monacoInstance;
// Ctrl+S / Cmd+S save shortcut inside Monaco
monacoInstance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
saveChanges();
});
// Format document action button
const formatBtn = footer.querySelector('.monaco-format-btn');
formatBtn.addEventListener('click', () => {
if (monacoInstance) {
monacoInstance.getAction('editor.action.formatDocument').run();
}
});
// Toggle Word Wrap
wrapBtn.addEventListener('click', () => {
if (!monacoInstance) return;
isWordWrap = !isWordWrap;
monacoInstance.updateOptions({ wordWrap: isWordWrap ? 'on' : 'off' });
wrapBtn.innerHTML = `<i class="fas fa-align-left" style="color:#9cdcfe;"></i> Word Wrap: ${isWordWrap ? 'On' : 'Off'}`;
});
});
}
save(blockContent) {
if (this.activeMonacoInstance) {
try {
this.data.html = this.activeMonacoInstance.getValue();
} catch (e) {}
} else if (this.editorInstance) {
try {
this.editorInstance.save();
const content = this.editorInstance.getContent({ format: 'raw' });
if (content !== undefined && content !== null) {
this.data.html = content;
}
} catch (e) {
console.warn('[SIS TinyMCE Tool] Failed to get editor content:', e);
}
} else if (this.textareaElement) {
this.data.html = this.textareaElement.value;
}
return { return {
html: this.data.html html: this.data.html || '',
cssClass: this.cssClassInput ? this.cssClassInput.value.trim() : (this.data.cssClass || ''),
elementId: this.elementIdInput ? this.elementIdInput.value.trim() : (this.data.elementId || ''),
customStyle: this.customStyleInput ? this.customStyleInput.value.trim() : (this.data.customStyle || ''),
customAttrs: this.customAttrsInput ? this.customAttrsInput.value.trim() : (this.data.customAttrs || '')
}; };
} }
@@ -52,12 +52,23 @@
/* ── initModal ─────────────────────────────────────── */ /* ── initModal ─────────────────────────────────────── */
initModal: function () { initModal: function () {
if (!document.getElementById('sisMediaPickerStyle')) {
var styleEl = document.createElement('style');
styleEl.id = 'sisMediaPickerStyle';
styleEl.innerHTML = '#sisMediaLibraryModal { z-index: 999999 !important; } .sis-media-backdrop { z-index: 999990 !important; }';
document.head.appendChild(styleEl);
}
var modalEl = document.getElementById(this.modalId); var modalEl = document.getElementById(this.modalId);
if (modalEl) return modalEl; if (modalEl) {
modalEl.style.setProperty('z-index', '999999', 'important');
return modalEl;
}
modalEl = document.createElement('div'); modalEl = document.createElement('div');
modalEl.id = this.modalId; modalEl.id = this.modalId;
modalEl.className = 'modal fade'; modalEl.className = 'modal fade';
modalEl.style.setProperty('z-index', '999999', 'important');
modalEl.setAttribute('tabindex', '-1'); modalEl.setAttribute('tabindex', '-1');
modalEl.setAttribute('role', 'dialog'); modalEl.setAttribute('role', 'dialog');
modalEl.setAttribute('aria-hidden', 'true'); modalEl.setAttribute('aria-hidden', 'true');
@@ -357,17 +368,35 @@
initialConfig = arg1.config || arg1.currentConfig || arg1.initialConfig || arg1; initialConfig = arg1.config || arg1.currentConfig || arg1.initialConfig || arg1;
if (typeof arg2 === 'function') { if (typeof arg2 === 'function') {
callback = arg2; callback = arg2;
} else if (typeof arg1.onSelect === 'function') {
callback = arg1.onSelect;
} else if (typeof arg1.callback === 'function') {
callback = arg1.callback;
} }
} }
this.callback = callback; this.callback = callback;
var modalEl = this.initModal(); var modalEl = this.initModal();
modalEl.style.setProperty('z-index', '999999', 'important');
if (typeof $ !== 'undefined' && $.fn && $.fn.modal) { if (typeof $ !== 'undefined' && $.fn && $.fn.modal) {
$(modalEl).off('show.bs.modal.sis z-index.sis').on('show.bs.modal.sis', function () {
modalEl.style.setProperty('z-index', '999999', 'important');
setTimeout(function() {
modalEl.style.setProperty('z-index', '999999', 'important');
var backdrops = document.querySelectorAll('.modal-backdrop');
if (backdrops.length > 0) {
var lastBackdrop = backdrops[backdrops.length - 1];
lastBackdrop.classList.add('sis-media-backdrop');
lastBackdrop.style.setProperty('z-index', '999990', 'important');
}
}, 50);
});
$(modalEl).modal('show'); $(modalEl).modal('show');
} else { } else {
modalEl.style.display = 'block'; modalEl.style.display = 'block';
modalEl.classList.add('show'); modalEl.classList.add('show');
modalEl.style.setProperty('z-index', '999999', 'important');
} }
// Reset search input // Reset search input
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,10 @@
<!-- 🟢 1. SWIPER.JS CSS (Đã thêm) --> <!-- 🟢 1. SWIPER.JS CSS (Đã thêm) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css" />
<!-- FontAwesome & Bootstrap Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<!-- Custom CSS (Highest Priority) --> <!-- Custom CSS (Highest Priority) -->
<link rel="stylesheet" th:href="@{/css/custom.css}" /> <link rel="stylesheet" th:href="@{/css/custom.css}" />
</head> </head>
@@ -23,6 +23,9 @@
<!-- Project-wide CSS Variables & Custom Styles --> <!-- Project-wide CSS Variables & Custom Styles -->
<link rel="stylesheet" th:href="@{/css/custom.css}"> <link rel="stylesheet" th:href="@{/css/custom.css}">
<!-- Select2 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2-bootstrap-theme/0.1.0-beta.10/select2-bootstrap.min.css">
<!-- Admin Head Hook --> <!-- Admin Head Hook -->
<th:block th:utext="${hookManager.doActionAndReturn('admin_head')}"></th:block> <th:block th:utext="${hookManager.doActionAndReturn('admin_head')}"></th:block>
</head> </head>
@@ -407,6 +410,13 @@
<!-- Bootstrap core JavaScript--> <!-- Bootstrap core JavaScript-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script th:inline="javascript">
$(document).ajaxError(function(event, jqXHR, settings, thrownError) {
if (jqXHR.status === 401 || jqXHR.status === 403) {
window.location.href = /*[[@{/manage/login?expired}]]*/ '/manage/login?expired';
}
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.0/js/bootstrap.bundle.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.0/js/bootstrap.bundle.min.js"></script>
<!-- Core plugin JavaScript--> <!-- Core plugin JavaScript-->
@@ -415,6 +425,9 @@
<!-- Custom scripts for all pages--> <!-- Custom scripts for all pages-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/startbootstrap-sb-admin-2/4.1.4/js/sb-admin-2.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/startbootstrap-sb-admin-2/4.1.4/js/sb-admin-2.min.js"></script>
<!-- Select2 JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js"></script>
<!-- Page-specific scripts injected by child templates --> <!-- Page-specific scripts injected by child templates -->
<section layout:fragment="scripts"></section> <section layout:fragment="scripts"></section>
@@ -49,6 +49,9 @@
<div th:if="${param.logout}" class="alert alert-info"> <div th:if="${param.logout}" class="alert alert-info">
<span>Bạn đã đăng xuất thành công / You have been logged out.</span> <span>Bạn đã đăng xuất thành công / You have been logged out.</span>
</div> </div>
<div th:if="${param.expired}" class="alert alert-warning">
<span>Phiên làm việc đã hết hạn. Vui lòng đăng nhập lại. / Session expired. Please log in again.</span>
</div>
<form th:action="@{/manage/login}" class="form fv-plugins-bootstrap fv-plugins-framework" method="post"> <form th:action="@{/manage/login}" class="form fv-plugins-bootstrap fv-plugins-framework" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" /> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
@@ -226,6 +226,9 @@
<i class="fab fa-css3-alt"></i> Custom CSS (Page-Specific Styles) <i class="fab fa-css3-alt"></i> Custom CSS (Page-Specific Styles)
</label> </label>
<div> <div>
<button type="button" class="btn btn-outline-info btn-sm px-2 py-0 mr-1" id="btnCompileCssNestingPage" title="Auto-flatten CSS nesting rules to standard flat CSS" style="font-size: 11px;">
<i class="fas fa-magic"></i> Auto-Flatten Nesting
</button>
<button type="button" class="btn btn-outline-primary btn-sm px-2 py-0 mr-1" id="btnGenMediaQueryPage" title="Auto-generate Media Queries for Mobile, Tablet & Desktop" style="font-size: 11px;"> <button type="button" class="btn btn-outline-primary btn-sm px-2 py-0 mr-1" id="btnGenMediaQueryPage" title="Auto-generate Media Queries for Mobile, Tablet & Desktop" style="font-size: 11px;">
<i class="fas fa-mobile-alt"></i> Auto-Generate Media Queries <i class="fas fa-mobile-alt"></i> Auto-Generate Media Queries
</button> </button>
@@ -460,12 +463,14 @@
<script th:src="@{/js/manage/editor-plugins/nav.js}"></script> <script th:src="@{/js/manage/editor-plugins/nav.js}"></script>
<script th:src="@{/js/manage/editor-plugins/hero-banner.js}"></script> <script th:src="@{/js/manage/editor-plugins/hero-banner.js}"></script>
<script th:src="@{/js/manage/editor-plugins/text-styling.js}"></script> <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/tiny-mce.js(v=4)}"></script>
<script th:src="@{/js/manage/editor-plugins/posts.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/tab-plugin.js}"></script>
<script th:src="@{/js/manage/editor-plugins/custom-image.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/cms_plugin.js}"></script>
<script th:src="@{/js/manage/editor-plugins/youtube-plugin.js}"></script> <script th:src="@{/js/manage/editor-plugins/youtube-plugin.js}"></script>
<script th:src="@{/js/manage/editor-plugins/googleForm.js}"></script>
<script th:src="@{/js/manage/editor-plugins/cards.js}"></script>
<!-- SIS Standalone Media Picker Library --> <!-- SIS Standalone Media Picker Library -->
<script th:src="@{/js/manage/sis-media-picker.js}"></script> <script th:src="@{/js/manage/sis-media-picker.js}"></script>
@@ -701,6 +706,85 @@
textarea.focus(); textarea.focus();
} }
// CSS Nesting Auto-Flattener Helper
function flattenCssNesting(css) {
if (!css || typeof css !== 'string' || css.indexOf('{') === -1) return css;
function processRules(cssText, parentSelector) {
var result = '';
var currentProps = '';
var i = 0;
while (i < cssText.length) {
var char = cssText[i];
if (char === '{') {
var lastTerminator = Math.max(currentProps.lastIndexOf(';'), currentProps.lastIndexOf('}'));
var rawSelector = (lastTerminator >= 0 ? currentProps.substring(lastTerminator + 1) : currentProps).trim();
var propsBefore = (lastTerminator >= 0 ? currentProps.substring(0, lastTerminator + 1) : '').trim();
var depth = 1;
var start = i + 1;
i++;
while (i < cssText.length && depth > 0) {
if (cssText[i] === '{') depth++;
else if (cssText[i] === '}') depth--;
i++;
}
var blockContent = cssText.substring(start, i - 1);
if (rawSelector.indexOf('@') === 0) {
var innerFlat = processRules(blockContent, parentSelector);
result += (propsBefore ? propsBefore + '\n' : '') + rawSelector + ' {\n' + innerFlat + '\n}\n';
} else {
var fullSelector = rawSelector;
if (parentSelector) {
var parents = parentSelector.split(',');
var children = rawSelector.split(',');
var combined = [];
parents.forEach(function(p) {
children.forEach(function(c) {
p = p.trim();
c = c.trim();
if (c.indexOf('&') === 0) {
combined.push(c.replace(/^&/, p));
} else {
combined.push(p + ' ' + c);
}
});
});
fullSelector = combined.join(', ');
}
var innerFlat = processRules(blockContent, fullSelector);
result += (propsBefore ? propsBefore + '\n' : '') + innerFlat;
}
currentProps = '';
continue;
} else {
currentProps += char;
}
i++;
}
if (currentProps.trim() && parentSelector) {
result = parentSelector + ' {\n ' + currentProps.trim().replace(/;\s*/g, ';\n ') + '\n}\n' + result;
} else if (currentProps.trim()) {
result += currentProps;
}
return result;
}
try {
return processRules(css, '').trim();
} catch (e) {
console.warn('[SIS CSS Flatten Error]', e);
return css;
}
}
var btnCompileCss = document.getElementById('btnCompileCssNestingPage');
if (btnCompileCss) {
btnCompileCss.addEventListener('click', function () {
var textarea = document.getElementById('customCss');
if (textarea && textarea.value) {
textarea.value = flattenCssNesting(textarea.value);
}
});
}
var btnGenCss = document.getElementById('btnGenMediaQueryPage'); var btnGenCss = document.getElementById('btnGenMediaQueryPage');
if (btnGenCss) { if (btnGenCss) {
btnGenCss.addEventListener('click', function () { btnGenCss.addEventListener('click', function () {
@@ -708,6 +792,17 @@
}); });
} }
// Ensure CSS is flattened on form submission
var pageForm = document.getElementById('pageForm');
if (pageForm) {
pageForm.addEventListener('submit', function () {
var textarea = document.getElementById('customCss');
if (textarea && textarea.value) {
textarea.value = flattenCssNesting(textarea.value);
}
});
}
// Tab indent support for Code Textareas // Tab indent support for Code Textareas
function enableTabIndentation(textarea) { function enableTabIndentation(textarea) {
if (!textarea) return; if (!textarea) return;
@@ -214,6 +214,9 @@
<i class="fab fa-css3-alt"></i> Custom CSS (Post-Specific Styles) <i class="fab fa-css3-alt"></i> Custom CSS (Post-Specific Styles)
</label> </label>
<div> <div>
<button type="button" class="btn btn-outline-info btn-sm px-2 py-0 mr-1" id="btnCompileCssNestingPost" title="Auto-flatten CSS nesting rules to standard flat CSS" style="font-size: 11px;">
<i class="fas fa-magic"></i> Auto-Flatten Nesting
</button>
<button type="button" class="btn btn-outline-primary btn-sm px-2 py-0 mr-1" id="btnGenMediaQueryPost" title="Auto-generate Media Queries for Mobile, Tablet & Desktop" style="font-size: 11px;"> <button type="button" class="btn btn-outline-primary btn-sm px-2 py-0 mr-1" id="btnGenMediaQueryPost" title="Auto-generate Media Queries for Mobile, Tablet & Desktop" style="font-size: 11px;">
<i class="fas fa-mobile-alt"></i> Auto-Generate Media Queries <i class="fas fa-mobile-alt"></i> Auto-Generate Media Queries
</button> </button>
@@ -922,6 +925,102 @@
} }
}); });
} }
// CSS Nesting Auto-Flattener Helper
function flattenCssNesting(css) {
if (!css || typeof css !== 'string' || css.indexOf('{') === -1) return css;
function processRules(cssText, parentSelector) {
var result = '';
var currentProps = '';
var i = 0;
while (i < cssText.length) {
var char = cssText[i];
if (char === '{') {
var lastTerminator = Math.max(currentProps.lastIndexOf(';'), currentProps.lastIndexOf('}'));
var rawSelector = (lastTerminator >= 0 ? currentProps.substring(lastTerminator + 1) : currentProps).trim();
var propsBefore = (lastTerminator >= 0 ? currentProps.substring(0, lastTerminator + 1) : '').trim();
var depth = 1;
var start = i + 1;
i++;
while (i < cssText.length && depth > 0) {
if (cssText[i] === '{') depth++;
else if (cssText[i] === '}') depth--;
i++;
}
var blockContent = cssText.substring(start, i - 1);
if (rawSelector.indexOf('@') === 0) {
var innerFlat = processRules(blockContent, parentSelector);
result += (propsBefore ? propsBefore + '\n' : '') + rawSelector + ' {\n' + innerFlat + '\n}\n';
} else {
var fullSelector = rawSelector;
if (parentSelector) {
var parents = parentSelector.split(',');
var children = rawSelector.split(',');
var combined = [];
parents.forEach(function(p) {
children.forEach(function(c) {
p = p.trim();
c = c.trim();
if (c.indexOf('&') === 0) {
combined.push(c.replace(/^&/, p));
} else {
combined.push(p + ' ' + c);
}
});
});
fullSelector = combined.join(', ');
}
var innerFlat = processRules(blockContent, fullSelector);
result += (propsBefore ? propsBefore + '\n' : '') + innerFlat;
}
currentProps = '';
continue;
} else {
currentProps += char;
}
i++;
}
if (currentProps.trim() && parentSelector) {
result = parentSelector + ' {\n ' + currentProps.trim().replace(/;\s*/g, ';\n ') + '\n}\n' + result;
} else if (currentProps.trim()) {
result += currentProps;
}
return result;
}
try {
return processRules(css, '').trim();
} catch (e) {
console.warn('[SIS CSS Flatten Error]', e);
return css;
}
}
var btnCompileCss = document.getElementById('btnCompileCssNestingPost');
if (btnCompileCss) {
btnCompileCss.addEventListener('click', function () {
var textarea = document.getElementById('postCustomCss');
if (textarea && textarea.value) {
textarea.value = flattenCssNesting(textarea.value);
}
});
}
var btnGenCssPost = document.getElementById('btnGenMediaQueryPost');
if (btnGenCssPost) {
btnGenCssPost.addEventListener('click', function () {
insertResponsiveMediaQueries(document.getElementById('postCustomCss'));
});
}
var postForm = document.getElementById('postForm');
if (postForm) {
postForm.addEventListener('submit', function () {
var textarea = document.getElementById('postCustomCss');
if (textarea && textarea.value) {
textarea.value = flattenCssNesting(textarea.value);
}
});
}
enableTabIndentation(document.getElementById('postCustomCss')); enableTabIndentation(document.getElementById('postCustomCss'));
enableTabIndentation(document.getElementById('postCustomJs')); enableTabIndentation(document.getElementById('postCustomJs'));
}); });
@@ -3,7 +3,7 @@
lang="en" lang="en"
xmlns:th="http://www.thymeleaf.org" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{themes/__${activeTheme}__/layout}" layout:decorate="~{themes/__${activeTheme}__/layout(bodyClass=${bodyClass})}"
> >
<head> <head>
<title th:text="${page.title}">Bệnh Viện S.I.S Cần Thơ</title> <title th:text="${page.title}">Bệnh Viện S.I.S Cần Thơ</title>
@@ -12,6 +12,8 @@
<!-- Load lightweight Bootstrap Grid CSS to support grid/flex block layouts without overriding theme styles --> <!-- Load lightweight Bootstrap Grid CSS to support grid/flex block layouts without overriding theme styles -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap-grid.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap-grid.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<!-- Customizer CSS overrides --> <!-- Customizer CSS overrides -->
<style th:inline="css"> <style th:inline="css">
@@ -38,7 +40,10 @@
> >
<!-- 1. HTML Snippet / CMS Plugin Block --> <!-- 1. HTML Snippet / CMS Plugin Block -->
<th:block th:if="${block['type'] == 'snippet' or block['type'] == 'cmsPlugin'}"> <th:block th:if="${block['type'] == 'snippet' or block['type'] == 'cmsPlugin'}">
<div th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"> <div th:classappend="${(block['cssClass'] != null ? block['cssClass'] + ' ' : '') + (block.data != null and block.data['cssClass'] != null ? block.data['cssClass'] : '')}"
th:id="${block['elementId'] != null and !#strings.isEmpty(block['elementId']) ? block['elementId'] : (block.data != null and block.data['elementId'] != null ? block.data['elementId'] : '')}"
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] + ';' : '') + (block.data != null and block.data['customStyle'] != null ? block.data['customStyle'] + ';' : '') + (block['alignment'] != null ? ' text-align: ' + block['alignment'] + ';' : '')}"
th:attr="data-custom-attrs=${block['customAttrs'] != null ? block['customAttrs'] : (block.data != null and block.data['customAttrs'] != null ? block.data['customAttrs'] : '')}">
<th:block th:if="${block['type'] == 'snippet'}"> <th:block th:if="${block['type'] == 'snippet'}">
<div th:utext="${block.data['htmlContent']}" th:remove="tag"></div> <div th:utext="${block.data['htmlContent']}" th:remove="tag"></div>
</th:block> </th:block>
@@ -133,9 +138,14 @@
<!-- TinyMCE Rich Editor Block --> <!-- TinyMCE Rich Editor Block -->
<th:block th:if="${block['type'] == 'tinymce'}"> <th:block th:if="${block['type'] == 'tinymce'}">
<div th:utext="${block.data['html']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></div> <div th:utext="${block.data['html']}"
th:class="${(block.data['cssClass'] != null ? block.data['cssClass'] : '') + (block['cssClass'] != null ? ' ' + block['cssClass'] : '')}"
th:id="${block.data['elementId'] != null and !block.data['elementId'].isEmpty() ? block.data['elementId'] : block['elementId']}"
th:style="${(block.data['customStyle'] != null ? block.data['customStyle'] + ';' : '') + (block['customStyle'] != null ? block['customStyle'] + ';' : '') + (block.data['alignment'] != null ? 'text-align: ' + block.data['alignment'] + ';' : '')}"
th:attr="data-custom-attrs=${block.data['customAttrs'] != null and !block.data['customAttrs'].isEmpty() ? block.data['customAttrs'] : block['customAttrs']}"></div>
</th:block> </th:block>
<!-- 10. Accordion Block --> <!-- 10. Accordion Block -->
<th:block th:if="${block['type'] == 'accordion'}"> <th:block th:if="${block['type'] == 'accordion'}">
<details class="sis-accordion-item mb-3" th:open="${block.data['isOpen'] == true}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"> <details class="sis-accordion-item mb-3" th:open="${block.data['isOpen'] == true}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
@@ -195,9 +205,9 @@
<!-- 12. Hero Banner Block --> <!-- 12. Hero Banner Block -->
<th:block th:if="${block['type'] == 'hero'}"> <th:block th:if="${block['type'] == 'hero'}">
<div <div
class="sis-hero-banner text-white py-5 px-3 rounded shadow-sm text-center" class="sis-hero-banner text-white py-5 px-3 rounded shadow-sm"
th:classappend="${block['cssClass']}" th:classappend="${(block.data != null and (block.data['stretched'] == true or block.data['stretched'] == 'true') or block['stretched'] == true ? 'sis-fullwidth-breakout ' : '') + (block.data != null and block.data['textAlign'] == 'left' ? 'text-left ' : (block.data != null and block.data['textAlign'] == 'right' ? 'text-right ' : 'text-center ')) + (block.data != null and block.data['cssClass'] != null ? block.data['cssClass'] : (block['cssClass'] != null ? block['cssClass'] : ''))}"
th:id="${block['elementId'] != null ? block['elementId'] : null}" th:id="${(block.data != null and block.data['elementId'] != null and !#strings.isEmpty(block.data['elementId'])) ? block.data['elementId'] : (block['elementId'] != null ? block['elementId'] : null)}"
style=" style="
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -208,22 +218,75 @@
background-position: center; background-position: center;
background-color: #002554; background-color: #002554;
" "
th:styleappend="${'min-height: ' + (block.data['height'] != null and !#strings.isEmpty(block.data['height']) ? block.data['height'] : '350px') + ';' + ((block.data['bgImage'] != null and !#strings.isEmpty(block.data['bgImage'])) ? 'background-image: url(' + block.data['bgImage'] + ');' : 'background: linear-gradient(135deg, #002554 0%, #881C1C 100%);') + (block['customStyle'] != null ? '; ' + block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] : '')}" th:styleappend="${'min-height: ' + (block.data != null and block.data['height'] != null and !#strings.isEmpty(block.data['height']) ? block.data['height'] : '350px') + ';' + (block.data != null and block.data['bgImage'] != null and !#strings.isEmpty(block.data['bgImage']) ? 'background-image: url(' + block.data['bgImage'] + ');' : 'background: linear-gradient(135deg, #002554 0%, #881C1C 100%);') + (block.data != null and block.data['textAlign'] != null ? '; text-align: ' + block.data['textAlign'] : '') + (block.data != null and block.data['customStyle'] != null ? '; ' + block.data['customStyle'] : (block['customStyle'] != null ? '; ' + block['customStyle'] : '')) + (block['alignment'] != null ? '; text-align: ' + block['alignment'] : '')}"
th:attr="data-custom-attrs=${block['customAttrs']}" th:attr="data-custom-attrs=${(block.data != null and block.data['customAttrs'] != null) ? block.data['customAttrs'] : block['customAttrs']}"
> >
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.4); z-index: 1"></div> <div class="sis-hero-overlay"
<div class="container" style="position: relative; z-index: 2"> style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 1;"
th:attr="data-type=${block.data['gradientType'] != null ? block.data['gradientType'] : 'dark_bottom'},
data-c1=${block.data['gradientColor1'] != null ? block.data['gradientColor1'] : '#000000'},
data-c2=${block.data['gradientColor2'] != null ? block.data['gradientColor2'] : '#1e3a5f'},
data-opacity=${block.data['overlayOpacity'] != null ? block.data['overlayOpacity'] : '0.5'},
data-angle=${block.data['gradientAngle'] != null ? block.data['gradientAngle'] : '160'},
data-rx=${block.data['gradientRadialX'] != null ? block.data['gradientRadialX'] : '50'},
data-ry=${block.data['gradientRadialY'] != null ? block.data['gradientRadialY'] : '50'}">
</div>
<script>
(function() {
var overlay = document.currentScript.previousElementSibling;
if (!overlay || !overlay.classList.contains('sis-hero-overlay')) return;
function hex2rgba(hex, alpha) {
hex = (hex || '#000000').replace('#', '');
if (hex.length === 3) hex = hex.split('').map(function(c) { return c + c; }).join('');
var r = parseInt(hex.substring(0, 2), 16) || 0;
var g = parseInt(hex.substring(2, 4), 16) || 0;
var b = parseInt(hex.substring(4, 6), 16) || 0;
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
}
var type = overlay.getAttribute('data-type') || 'dark_bottom';
var c1 = overlay.getAttribute('data-c1') || '#000000';
var c2 = overlay.getAttribute('data-c2') || '#1e3a5f';
var op = parseFloat(overlay.getAttribute('data-opacity') || '0.5');
var angle = overlay.getAttribute('data-angle') || '160';
var rx = overlay.getAttribute('data-rx') || '50';
var ry = overlay.getAttribute('data-ry') || '50';
var grad = 'rgba(0,0,0,' + op + ')';
if (type === 'none') { grad = 'rgba(0,0,0,' + op + ')'; }
else if (type === 'linear') { grad = 'linear-gradient(' + angle + 'deg, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, 0) + ')'; }
else if (type === 'linear_both' || type === 'custom_color') { grad = 'linear-gradient(' + angle + 'deg, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, op) + ')'; }
else if (type === 'radial') { grad = 'radial-gradient(ellipse at ' + rx + '% ' + ry + '%, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, 0) + ')'; }
else if (type === 'radial_both') { grad = 'radial-gradient(ellipse at ' + rx + '% ' + ry + '%, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, op) + ')'; }
else if (type === 'dark_bottom') { grad = 'linear-gradient(to top, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
else if (type === 'dark_top') { grad = 'linear-gradient(to bottom, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
else if (type === 'dark_left') { grad = 'linear-gradient(to right, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
else if (type === 'dark_right') { grad = 'linear-gradient(to left, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
else if (type === 'spotlight') { grad = 'radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0) 20%, rgba(0,0,0,' + op + ') 80%)'; }
else if (type === 'vignette') { grad = 'radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0) 40%, rgba(0,0,0,' + op + ') 100%)'; }
else if (type === 'duo_blue') { grad = 'linear-gradient(' + angle + 'deg, rgba(2,132,199,' + op + '), rgba(15,23,42,' + op + '))'; }
else if (type === 'duo_purple') { grad = 'linear-gradient(' + angle + 'deg, rgba(126,34,206,' + op + '), rgba(15,23,42,' + op + '))'; }
else if (type === 'duo_sunset') { grad = 'linear-gradient(' + angle + 'deg, rgba(220,38,38,' + op + '), rgba(249,115,22,' + op + '), rgba(234,179,8,' + op + '))'; }
else if (type === 'duo_ocean') { grad = 'linear-gradient(' + angle + 'deg, rgba(6,182,212,' + op + '), rgba(37,99,235,' + op + '))'; }
else if (type === 'duo_forest') { grad = 'linear-gradient(' + angle + 'deg, rgba(22,163,74,' + op + '), rgba(15,23,42,' + op + '))'; }
else if (type === 'duo_rose') { grad = 'linear-gradient(' + angle + 'deg, rgba(244,63,94,' + op + '), rgba(251,191,36,' + op + '))'; }
overlay.style.background = grad;
})();
</script>
<div class="container" style="position: relative; z-index: 2"
th:styleappend="${'color: ' + (block.data != null and block.data['textColor'] != null and !#strings.isEmpty(block.data['textColor']) ? block.data['textColor'] : '#ffffff') + ' !important;'}">
<h1 <h1
class="display-4 font-weight-bold text-white mb-3" class="display-4 font-weight-bold mb-3"
th:if="${block.data['title'] != null and !#strings.isEmpty(block.data['title'])}" th:if="${block.data['title'] != null and !#strings.isEmpty(block.data['title'])}"
th:utext="${block.data['title']}" th:utext="${block.data['title']}"
th:style="${'color: ' + (block.data != null and block.data['textColor'] != null and !#strings.isEmpty(block.data['textColor']) ? block.data['textColor'] : '#ffffff') + ' !important;'}"
> >
Hero Banner Title Hero Banner Title
</h1> </h1>
<p <p
class="lead text-light mb-4" class="lead mb-4"
th:if="${block.data['subtitle'] != null and !#strings.isEmpty(block.data['subtitle'])}" th:if="${block.data['subtitle'] != null and !#strings.isEmpty(block.data['subtitle'])}"
th:utext="${block.data['subtitle']}" th:utext="${block.data['subtitle']}"
th:style="${'color: ' + (block.data != null and block.data['textColor'] != null and !#strings.isEmpty(block.data['textColor']) ? block.data['textColor'] : '#ffffff') + ' !important;'}"
></p> ></p>
<a <a
th:if="${block.data['btnText'] != null and !#strings.isEmpty(block.data['btnText'])}" th:if="${block.data['btnText'] != null and !#strings.isEmpty(block.data['btnText'])}"
@@ -410,7 +473,7 @@
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}" <div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : ''}" th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : ''}"
th:classappend="${block['cssClass']}" th:classappend="${block['cssClass']}"
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:styleappend="${(block.data['globalStyle'] != null and !#strings.isEmpty(block.data['globalStyle']) ? block.data['globalStyle'] + ';' : '') + (block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
th:attr="data-custom-attrs=${block['customAttrs']}" th:attr="data-custom-attrs=${block['customAttrs']}"
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}"> th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
<div class="container py-4"> <div class="container py-4">
@@ -466,6 +529,40 @@
</div> </div>
</th:block> </th:block>
<!-- Practice Cards Block (Untitled-5.html) -->
<th:block th:if="${block['type'] == 'cards' or block['type'] == 'practiceCards' or block['type'] == 'practice-cards'}">
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : 'sis-practice-card-section'}"
th:classappend="${block['cssClass']}"
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
th:attr="data-custom-attrs=${block['customAttrs']}"
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : null}">
<div class="container py-4">
<div class="row g-4 align-items-stretch">
<th:block th:each="item : ${block.data['items']}">
<div th:if="${item['active'] == null or item['active'] == true}"
th:class="${block.data['cols'] == 1 ? 'col-12 mb-4' : (block.data['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (block.data['cols'] == 3 ? 'col-lg-4 col-md-6 col-12 mb-4' : (block.data['cols'] == 6 ? 'col-lg-2 col-md-4 col-6 mb-4' : 'col-lg-3 col-md-6 col-12 mb-4')))}">
<div class="practice-card">
<div class="card-title-custom" th:utext="${item['title']}">Title</div>
<div class="card-footer-custom">
<span class="date-text" th:if="${item['date'] != null and !#strings.isEmpty(item['date'])}" th:text="${item['date']}">22/06/2026</span>
<a th:href="${item['linkUrl'] != null and !#strings.isEmpty(item['linkUrl']) ? item['linkUrl'] : '#'}"
th:target="${item['linkTarget'] != null ? item['linkTarget'] : '_self'}"
class="action-link">
<span th:text="${item['linkText'] != null and !#strings.isEmpty(item['linkText']) ? item['linkText'] : 'Tìm hiểu thêm'}">Tìm hiểu thêm</span>
<div class="icon-circle">
<i class="fas fa-chevron-right"></i>
</div>
</a>
</div>
</div>
</div>
</th:block>
</div>
</div>
</div>
</th:block>
<!-- 18. Tab Panel Plugin Block --> <!-- 18. Tab Panel Plugin Block -->
<th:block th:if="${block['type'] == 'tabPlugin' or block['type'] == 'tab-plugin' or block['type'] == 'tab_plugin'}"> <th:block th:if="${block['type'] == 'tabPlugin' or block['type'] == 'tab-plugin' or block['type'] == 'tab_plugin'}">
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}" <div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
@@ -529,8 +626,24 @@
th:attr="data-custom-attrs=${block['customAttrs']}" th:attr="data-custom-attrs=${block['customAttrs']}"
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : null}"> th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : null}">
<div class="container py-4"> <div class="container py-4">
<!-- Shared Filter Tabs for ALL Dynamic Posts Layouts -->
<th:block th:if="${block.data['enableTabs'] == null or block.data['enableTabs'] == true}"> <!-- "Xem Thêm" link: shown when showViewMore is enabled and a URL is provided -->
<th:block th:if="${block.data['showViewMore'] == true and block.data['viewMoreUrl'] != null and !#strings.isEmpty(block.data['viewMoreUrl'])}">
<style>
.posts-view-more-wrap { display: flex; justify-content: flex-end; margin-bottom: 20px; }
.posts-view-more-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 20px; background-color: #0284c7; color: #ffffff; font-size: 14px; font-weight: 600; border-radius: 6px; text-decoration: none; transition: background-color 0.2s ease, transform 0.15s ease; }
.posts-view-more-link:hover { background-color: #0369a1; color: #ffffff; transform: translateY(-1px); text-decoration: none; }
</style>
<div class="posts-view-more-wrap">
<a th:href="${block.data['viewMoreUrl']}" class="posts-view-more-link">
Xem Thêm
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</a>
</div>
</th:block>
<!-- Filter Tabs: shown when enableTabs is on AND showViewMore is NOT replacing it -->
<th:block th:if="${(block.data['enableTabs'] == null or block.data['enableTabs'] == true) and !(block.data['showViewMore'] == true and block.data['viewMoreUrl'] != null and !#strings.isEmpty(block.data['viewMoreUrl']))}">
<style> <style>
.global-filter-tabs { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 24px; justify-content: flex-start; } .global-filter-tabs { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 24px; justify-content: flex-start; }
.global-filter-tabs .tab-btn { padding: 8px 18px; border: none; background-color: #f1f5f9; color: #475569; font-size: 14px; font-weight: 600; border-radius: 6px; cursor: pointer; transition: all 0.2s ease; user-select: none; } .global-filter-tabs .tab-btn { padding: 8px 18px; border: none; background-color: #f1f5f9; color: #475569; font-size: 14px; font-weight: 600; border-radius: 6px; cursor: pointer; transition: all 0.2s ease; user-select: none; }
@@ -723,7 +836,7 @@
) or ( ) or (
(p.category != null and block.data['categoryFilter'] != null and !#strings.isEmpty(block.data['categoryFilter']) and #strings.containsIgnoreCase(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ',')) (p.category != null and block.data['categoryFilter'] != null and !#strings.isEmpty(block.data['categoryFilter']) and #strings.containsIgnoreCase(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ','))
or or
(p.tags != null and block.data['tagFilter'] != null and !#strings.isEmpty(block.data['tagFilter']) and p.tags.?[#strings.containsIgnoreCase(',' + block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0) (p.tags != null and block.data['tagFilter'] != null and !#strings.isEmpty(block.data['tagFilter']) and p.tags.?[#strings.containsIgnoreCase(',' + #root.block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0)
)}"> )}">
<a th:href="@{'/posts/' + ${p.slug}}" <a th:href="@{'/posts/' + ${p.slug}}"
class="news-card" class="news-card"
@@ -888,7 +1001,7 @@
<th:block th:if="${block.data['displayStyle'] == 'umc-award-hero' or block.data['displayStyle'] == 'umc-5card-award'}"> <th:block th:if="${block.data['displayStyle'] == 'umc-award-hero' or block.data['displayStyle'] == 'umc-5card-award'}">
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-xl-5 g-3"> <div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-xl-5 g-3">
<th:block th:if="${posts != null}"> <th:block th:if="${posts != null}">
<th:block th:each="p : ${posts}" th:if="${(block.data['layoutFilter'] == null or block.data['layoutFilter'] == 'ALL' or (p.layout != null and #strings.equals(p.layout.name(), block.data['layoutFilter']))) and (block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or (p.category != null and #strings.containsIgnoreCase(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ','))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (p.tags != null and p.tags.?[#strings.containsIgnoreCase(',' + block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0))}"> <th:block th:each="p : ${posts}" th:if="${(block.data['layoutFilter'] == null or block.data['layoutFilter'] == 'ALL' or (p.layout != null and #strings.equals(p.layout.name(), block.data['layoutFilter']))) and (block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or (p.category != null and #strings.containsIgnoreCase(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ','))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (p.tags != null and p.tags.?[#strings.containsIgnoreCase(',' + #root.block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0))}">
<div class="col" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}"> <div class="col" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
<a th:href="@{'/posts/' + ${p.slug}}" class="card h-100 border-0 shadow-sm rounded-lg text-decoration-none overflow-hidden group"> <a th:href="@{'/posts/' + ${p.slug}}" class="card h-100 border-0 shadow-sm rounded-lg text-decoration-none overflow-hidden group">
<div class="ratio ratio-4x3 bg-light overflow-hidden"> <div class="ratio ratio-4x3 bg-light overflow-hidden">
@@ -933,8 +1046,11 @@
font-size: 12px; font-size: 12px;
color: #94a3b8; color: #94a3b8;
} }
.news-section-v3 .featured-post { .news-section-v3 .featured-post-wrapper,
.news-section-v3 .side-news-grid-wrapper {
grid-column: span 6; grid-column: span 6;
}
.news-section-v3 .featured-post {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
@@ -1033,6 +1149,8 @@
color: #0284c7; color: #0284c7;
} }
@media (max-width: 992px) { @media (max-width: 992px) {
.news-section-v3 .featured-post-wrapper,
.news-section-v3 .side-news-grid-wrapper,
.news-section-v3 .featured-post, .news-section-v3 .featured-post,
.news-section-v3 .side-news-grid { .news-section-v3 .side-news-grid {
grid-column: span 12; grid-column: span 12;
@@ -1118,10 +1236,38 @@
<!-- Standard Grid Layout --> <!-- Standard Grid Layout -->
<th:block th:if="${block.data['displayStyle'] != 'umc-award-hero' and block.data['displayStyle'] != 'umc-5card-award' and block.data['displayStyle'] != 'bento_layout_01' and block.data['displayStyle'] != 'bento_layout_02' and block.data['displayStyle'] != 'card_grid_layout' and block.data['displayStyle'] != 'news_featured_2x2'}"> <th:block th:if="${block.data['displayStyle'] != 'umc-award-hero' and block.data['displayStyle'] != 'umc-5card-award' and block.data['displayStyle'] != 'bento_layout_01' and block.data['displayStyle'] != 'bento_layout_02' and block.data['displayStyle'] != 'card_grid_layout' and block.data['displayStyle'] != 'news_featured_2x2'}">
<div class="row" th:classappend="${block.data['displayStyle'] == 'grid' or block.data['displayStyle'] == null ? '' : (block.data['displayStyle'] == 'compact' ? 'flex-column' : '')}"> <style>
.sis-posts-block .posts-items-row { display: flex; flex-wrap: wrap; margin-left: -12px; margin-right: -12px; }
.sis-posts-block .post-item-col { padding-left: 12px; padding-right: 12px; box-sizing: border-box; }
.sis-posts-block .card { display: flex; flex-direction: column; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); transition: transform 0.25s ease, box-shadow 0.25s ease; height: 100%; text-decoration: none !important; }
.sis-posts-block .card:hover { transform: translateY(-4px); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.1); }
.sis-posts-block .card-body { padding: 16px 18px 20px; display: flex; flex-direction: column; justify-content: space-between; flex-grow: 1; }
.sis-posts-block .card-title { font-size: 15px; font-weight: 700; line-height: 1.45; margin-bottom: 8px; margin-top: 4px; }
.sis-posts-block .card-title a { color: #0f172a !important; text-decoration: none !important; transition: color 0.2s ease; font-weight: 700 !important; }
.sis-posts-block .card-title a:hover { color: #0284c7 !important; text-decoration: none !important; }
.sis-posts-block .badge-primary { background-color: #0284c7 !important; color: #ffffff !important; font-size: 11px; font-weight: 600; padding: 4px 10px; border-radius: 20px; display: inline-block; text-decoration: none !important; }
.sis-posts-block .pagination-container,
.sis-posts-block ul.pagination { display: flex !important; justify-content: center !important; align-items: center !important; list-style: none !important; list-style-type: none !important; gap: 8px !important; margin-top: 28px !important; margin-bottom: 0 !important; padding-left: 0 !important; padding-right: 0 !important; width: 100% !important; flex-direction: row !important; }
.sis-posts-block .pagination-container li,
.sis-posts-block .pagination-container .page-item,
.sis-posts-block ul.pagination li { list-style: none !important; list-style-type: none !important; margin: 0 !important; padding: 0 !important; display: inline-flex !important; }
.sis-posts-block .pagination-container .page-link,
.sis-posts-block ul.pagination .page-link { display: inline-flex !important; justify-content: center !important; align-items: center !important; min-width: 38px !important; height: 38px !important; padding: 0 12px !important; border-radius: 8px !important; border: 1px solid #cbd5e1 !important; background-color: #ffffff !important; color: #475569 !important; text-decoration: none !important; font-size: 14px !important; font-weight: 600 !important; transition: all 0.2s ease !important; cursor: pointer !important; user-select: none !important; line-height: 1 !important; }
.sis-posts-block .pagination-container .page-link:hover,
.sis-posts-block ul.pagination .page-link:hover { border-color: #0284c7 !important; color: #0284c7 !important; background-color: #f0f9ff !important; text-decoration: none !important; }
.sis-posts-block .pagination-container .page-item.active .page-link,
.sis-posts-block .pagination-container .page-link.active,
.sis-posts-block ul.pagination li.active .page-link { background-color: #0284c7 !important; color: #ffffff !important; border-color: #0284c7 !important; box-shadow: 0 2px 6px rgba(2, 132, 199, 0.3) !important; }
.sis-posts-block .pagination-container .page-item.disabled .page-link,
.sis-posts-block ul.pagination li.disabled .page-link { opacity: 0.45 !important; pointer-events: none !important; cursor: not-allowed !important; }
</style>
<div class="row posts-items-row"
th:classappend="${block.data['displayStyle'] == 'grid' or block.data['displayStyle'] == null ? '' : (block.data['displayStyle'] == 'compact' ? 'flex-column' : '')}"
th:data-limit="${block.data['limit'] != null and block.data['limit'] > 0 ? block.data['limit'] : (block.data['itemsPerPage'] != null and block.data['itemsPerPage'] > 0 ? block.data['itemsPerPage'] : 12)}"
th:data-enable-pagination="${block.data['enablePagination'] == null or block.data['enablePagination'] == true}">
<th:block th:if="${posts != null}"> <th:block th:if="${posts != null}">
<th:block th:each="p : ${posts}" th:if="${(block.data['layoutFilter'] == null or block.data['layoutFilter'] == 'ALL' or (p.layout != null and #strings.equals(p.layout.name(), block.data['layoutFilter']))) and (block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or (p.category != null and #strings.containsIgnoreCase(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ','))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (p.tags != null and p.tags.?[#strings.containsIgnoreCase(',' + block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0))}"> <th:block th:each="p : ${posts}" th:if="${(block.data['layoutFilter'] == null or block.data['layoutFilter'] == 'ALL' or (p.layout != null and #strings.equals(p.layout.name(), block.data['layoutFilter']))) and (block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or (p.category != null and #strings.containsIgnoreCase(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ','))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (p.tags != null and p.tags.?[#strings.containsIgnoreCase(',' + #root.block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0))}">
<div th:class="${block.data['cols'] == 1 ? 'col-12 mb-4' : (block.data['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (block.data['cols'] == 4 ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg-4 col-md-6 col-12 mb-4'))}" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}"> <div class="post-item-col" th:classappend="${block.data['cols'] == 1 ? 'col-12 mb-4' : (block.data['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (block.data['cols'] == 4 ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg-4 col-md-6 col-12 mb-4'))}" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
<div class="card h-100 shadow-sm border-0 rounded-lg overflow-hidden"> <div class="card h-100 shadow-sm border-0 rounded-lg overflow-hidden">
<div th:if="${(block.data['showImage'] == null or block.data['showImage'] == true) and p.featuredImage != null and !#strings.isEmpty(p.featuredImage)}" style="height: 200px; overflow: hidden; background: #f8f9fa;"> <div th:if="${(block.data['showImage'] == null or block.data['showImage'] == true) and p.featuredImage != null and !#strings.isEmpty(p.featuredImage)}" style="height: 200px; overflow: hidden; background: #f8f9fa;">
<img th:src="${p.featuredImage}" th:alt="${p.title}" class="w-100 h-100" style="object-fit: cover;" /> <img th:src="${p.featuredImage}" th:alt="${p.title}" class="w-100 h-100" style="object-fit: cover;" />
@@ -1146,23 +1292,22 @@
</th:block> </th:block>
</th:block> </th:block>
</div> </div>
<ul class="pagination pagination-container justify-content-center mt-3 mb-0" th:if="${block.data['enablePagination'] == null or block.data['enablePagination'] == true}"></ul>
</th:block> </th:block>
<!-- Universal JS Initializer for Filter Tabs Across ALL Posts Layouts --> <!-- Universal JS Initializer for Filter Tabs & Pagination Across ALL Posts Layouts -->
<script th:if="${block.data['enableTabs'] == null or block.data['enableTabs'] == true}"> <script>
(function () { (function () {
function initGlobalPostsBlockTabs() { function initGlobalPostsBlock() {
const blocks = document.querySelectorAll('.sis-posts-block'); const blocks = document.querySelectorAll('.sis-posts-block');
blocks.forEach(function (block) { blocks.forEach(function (block) {
if (block.dataset.globalTabsInitialized) return; if (block.dataset.globalPostsInitialized) return;
block.dataset.globalTabsInitialized = 'true'; block.dataset.globalPostsInitialized = 'true';
const tabsContainer = block.querySelector('.filter-tabs'); const tabsContainer = block.querySelector('.filter-tabs');
if (!tabsContainer) return; if (tabsContainer) {
const rawConfigured = tabsContainer.getAttribute('data-configured-tabs') || ''; const rawConfigured = tabsContainer.getAttribute('data-configured-tabs') || '';
let tabNames = rawConfigured.split(',').map(function (s) { return s.trim(); }).filter(Boolean); let tabNames = rawConfigured.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
tabNames = tabNames.filter(function(name) { return name !== 'ALL' && name !== ''; }); tabNames = tabNames.filter(function(name) { return name !== 'ALL' && name !== ''; });
if (tabNames.length === 0) { if (tabNames.length === 0) {
@@ -1188,6 +1333,7 @@
tabsContainer.appendChild(btn); tabsContainer.appendChild(btn);
}); });
} }
}
function updateBento02Layout(activeFilter) { function updateBento02Layout(activeFilter) {
const container = block.querySelector('.news-section-v3'); const container = block.querySelector('.news-section-v3');
@@ -1287,9 +1433,92 @@
} }
} }
// Initial Bento 02 layout rendering // Initialize Bento 02 layout if present
updateBento02Layout('ALL'); updateBento02Layout('ALL');
// Pagination & Limit Initializer for Standard Grid Rows
const itemsRow = block.querySelector('.posts-items-row');
if (itemsRow) {
const limit = parseInt(itemsRow.getAttribute('data-limit')) || 12;
const enablePagination = itemsRow.getAttribute('data-enable-pagination') !== 'false';
const paginationUl = block.querySelector('.pagination-container');
const allCols = Array.from(itemsRow.querySelectorAll('.post-item-col'));
let currentPage = 1;
let currentFilter = 'ALL';
function renderGridPage() {
const visibleCols = allCols.filter(function (col) {
if (currentFilter === 'ALL') return true;
const target = currentFilter.trim().toLowerCase();
const cat = (col.getAttribute('data-category') || '').trim().toLowerCase();
const tags = (col.getAttribute('data-tags') || '').split(',').map(function (s) { return s.trim().toLowerCase(); });
return cat === target || tags.includes(target);
});
if (enablePagination) {
const totalPages = Math.ceil(visibleCols.length / limit) || 1;
if (currentPage > totalPages) currentPage = totalPages;
allCols.forEach(function (c) { c.style.display = 'none'; });
const startIdx = (currentPage - 1) * limit;
const endIdx = startIdx + limit;
visibleCols.slice(startIdx, endIdx).forEach(function (c) { c.style.display = ''; });
if (paginationUl) {
paginationUl.innerHTML = '';
if (totalPages <= 1) return;
const prevLi = document.createElement('li');
prevLi.className = 'page-item' + (currentPage === 1 ? ' disabled' : '');
prevLi.innerHTML = '<a class="page-link" href="javascript:void(0)">&laquo;</a>';
prevLi.addEventListener('click', function (e) {
e.preventDefault();
if (currentPage > 1) { currentPage--; renderGridPage(); }
});
paginationUl.appendChild(prevLi);
for (let i = 1; i <= totalPages; i++) {
const li = document.createElement('li');
li.className = 'page-item' + (i === currentPage ? ' active' : '');
li.innerHTML = '<a class="page-link" href="javascript:void(0)">' + i + '</a>';
li.addEventListener('click', (function (pageNum) {
return function (e) {
e.preventDefault();
currentPage = pageNum;
renderGridPage();
};
})(i));
paginationUl.appendChild(li);
}
const nextLi = document.createElement('li');
nextLi.className = 'page-item' + (currentPage === totalPages ? ' disabled' : '');
nextLi.innerHTML = '<a class="page-link" href="javascript:void(0)">&raquo;</a>';
nextLi.addEventListener('click', function (e) {
e.preventDefault();
if (currentPage < totalPages) { currentPage++; renderGridPage(); }
});
paginationUl.appendChild(nextLi);
}
} else {
allCols.forEach(function (c) { c.style.display = 'none'; });
visibleCols.slice(0, limit).forEach(function (c) { c.style.display = ''; });
if (paginationUl) paginationUl.innerHTML = '';
}
}
if (tabsContainer) {
tabsContainer.addEventListener('click', function (e) {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
currentFilter = btn.getAttribute('data-filter') || 'ALL';
currentPage = 1;
renderGridPage();
});
}
renderGridPage();
} else if (tabsContainer) {
tabsContainer.addEventListener('click', function (e) { tabsContainer.addEventListener('click', function (e) {
const btn = e.target.closest('.tab-btn'); const btn = e.target.closest('.tab-btn');
if (!btn) return; if (!btn) return;
@@ -1303,10 +1532,8 @@
} }
}); });
// Update Bento 02 Layout if present
updateBento02Layout(activeFilter); updateBento02Layout(activeFilter);
// Update standard grid & cards
const postItems = Array.from(block.querySelectorAll('.news-card, .bento-item, [data-category]:not(.featured-post):not(.small-post)')); const postItems = Array.from(block.querySelectorAll('.news-card, .bento-item, [data-category]:not(.featured-post):not(.small-post)'));
postItems.forEach(function (item) { postItems.forEach(function (item) {
if (activeFilter === 'ALL') { if (activeFilter === 'ALL') {
@@ -1323,13 +1550,14 @@
} }
}); });
}); });
}
}); });
} }
if (document.readyState === 'interactive' || document.readyState === 'complete') { if (document.readyState === 'interactive' || document.readyState === 'complete') {
initGlobalPostsBlockTabs(); initGlobalPostsBlock();
} else { } else {
document.addEventListener('DOMContentLoaded', initGlobalPostsBlockTabs); document.addEventListener('DOMContentLoaded', initGlobalPostsBlock);
} }
})(); })();
</script> </script>
@@ -1337,37 +1565,170 @@
</div> </div>
</th:block> </th:block>
<!-- 20. Form & Tri Ân Block (Interactive Custom Form & Google Form) -->
<th:block th:if="${block['type'] == 'googleForm' or block['type'] == 'google_form' or block['type'] == 'google-form'}">
<div class="sis-google-form-container my-4"
th:classappend="${((block.data != null and (block.data['stretched'] == true or block.data['fullWidth'] == true)) or block['stretched'] == true or block['fullWidth'] == true ? 'sis-fullwidth-breakout px-0 w-100 ' : '') + (block['cssClass'] != null ? block['cssClass'] : '')}"
th:id="${block['elementId']}"
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
th:attr="data-custom-attrs=${block['customAttrs']}">
<!-- Mode A: Google Form iFrame Embed -->
<th:block th:if="${block.data['formType'] == 'google_iframe'}">
<div class="p-3 p-sm-4 text-center"
th:style="${'border-radius:16px; background: ' + (block.data['theme'] == 'red_gradient' ? 'linear-gradient(135deg, #701a1a 0%, #d7193f 50%, #fff0f3 100%)' : (block.data['theme'] == 'teal_gradient' ? 'linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #f0fdfa 100%)' : (block.data['theme'] == 'light' ? '#f1f5f9' : 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #eff6ff 100%)')))}">
<iframe th:if="${block.data['iframeUrl'] != null and !#strings.isEmpty(block.data['iframeUrl'])}"
th:src="${block.data['iframeUrl']}"
style="width: 100%; border: none; border-radius: 12px; background: #ffffff; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);"
th:styleappend="${'height: ' + (block.data['iframeHeight'] != null and !#strings.isEmpty(block.data['iframeHeight']) ? block.data['iframeHeight'] : '650px') + ';'}"></iframe>
<th:block th:unless="${block.data['iframeUrl'] != null and !#strings.isEmpty(block.data['iframeUrl'])}">
<div th:utext="${hookManager.applyFilters('the_content', '[plugin:google-form' + (block.data['formId'] != null ? ' id=&quot;' + block.data['formId'] + '&quot;' : (block.data['id'] != null ? ' id=&quot;' + block.data['id'] + '&quot;' : '')) + ']')}" th:remove="tag"></div>
</th:block>
</div>
</th:block>
<!-- Mode B: Form Chia Sẻ Lời Tri Ân (Matching temp.html) -->
<th:block th:if="${block.data['formType'] == null or block.data['formType'] == 'tri_an'}">
<div class="p-3 p-sm-4 p-md-5 d-flex align-items-center justify-content-center"
th:style="${'min-height: 100%; border-radius: 20px; background: ' + (block.data['theme'] == 'red_gradient' ? 'linear-gradient(135deg, #701a1a 0%, #d7193f 50%, #fff0f3 100%)' : (block.data['theme'] == 'teal_gradient' ? 'linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #f0fdfa 100%)' : (block.data['theme'] == 'light' ? '#f1f5f9' : 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #eff6ff 100%)')))}">
<div class="bg-white rounded-2xl shadow-2xl w-100 p-4 p-sm-4 p-md-5 my-auto" style="max-width: 580px; border-radius: 16px; box-shadow: 0 20px 40px rgba(0,0,0,0.15);">
<h2 class="h4 font-weight-bold mb-3"
th:style="${'color: ' + (block.data['theme'] == 'red_gradient' ? '#881C1C' : (block.data['theme'] == 'teal_gradient' ? '#0f766e' : (block.data['theme'] == 'light' ? '#1e293b' : '#1e3a8a')))}"
th:text="${block.data['title'] != null and !#strings.isEmpty(block.data['title']) ? block.data['title'] : 'Chia sẻ lời tri ân'}">Chia sẻ lời tri ân</h2>
<p class="text-muted small mb-4" th:if="${block.data['subtitle'] != null and !#strings.isEmpty(block.data['subtitle'])}" th:utext="${block.data['subtitle']}"></p>
<form class="sis-tri-an-form"
th:data-action-url="${block.data['actionUrl']}"
th:data-success-msg="${block.data['successMsg'] != null and !#strings.isEmpty(block.data['successMsg']) ? block.data['successMsg'] : 'Gửi thông tin thành công!'}"
onsubmit="event.preventDefault(); var msg = this.getAttribute('data-success-msg'); alert(msg); this.reset();">
<div class="mb-3">
<label class="form-label font-weight-bold text-secondary small mb-1">Họ tên <span class="text-danger">*</span></label>
<input type="text" name="fullName" required placeholder="Nhập họ tên" class="form-control form-control-lg border-gray-300 rounded-lg text-sm" />
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-sm-6">
<label class="form-label font-weight-bold text-secondary small mb-1">Bạn là <span class="text-danger">*</span></label>
<select name="role" required class="form-select form-control form-control-lg border-gray-300 rounded-lg text-sm bg-white text-gray-500">
<option value="" disabled selected>Chọn vai trò</option>
<option value="bac-si">Bác sĩ / Nhân viên y tế</option>
<option value="benh-nhan">Bệnh nhân / Người nhà</option>
<option value="khac">Khác</option>
</select>
</div>
<div class="col-12 col-sm-6">
<label class="form-label font-weight-bold text-secondary small mb-1">Chức vụ/ Nơi làm việc <span class="text-danger">*</span></label>
<input type="text" name="workplace" required placeholder="Nhập chức vụ/ nơi làm việc" class="form-control form-control-lg border-gray-300 rounded-lg text-sm" />
</div>
</div>
<div class="mb-3">
<label class="form-label font-weight-bold text-secondary small mb-1">Số điện thoại <span class="text-danger">*</span></label>
<input type="tel" name="phone" required placeholder="Nhập số điện thoại" class="form-control form-control-lg border-gray-300 rounded-lg text-sm" />
</div>
<div class="mb-3">
<label class="form-label font-weight-bold text-secondary small mb-1">Nội dung lời nhắn <span class="text-danger">*</span></label>
<textarea name="message" rows="3" required placeholder="Nhập nội dung" class="form-control border-gray-300 rounded-lg text-sm" style="resize:none;"></textarea>
</div>
<div class="mb-4">
<label class="form-label font-weight-bold text-secondary small mb-1">Hình ảnh <span class="text-danger">*</span></label>
<label class="d-flex flex-column align-items-center justify-content-center border border-2 border-dashed border-gray-300 rounded-lg cursor-pointer bg-light p-3 text-muted hover-border-primary" style="width: 110px; height: 110px; cursor: pointer;">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" class="mb-1"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
<span class="small" style="font-size: 11px;">Tải tệp</span>
<input type="file" name="image" accept="image/*" class="d-none" required onchange="if(this.files &amp;&amp; this.files[0]){ this.previousElementSibling.textContent = this.files[0].name.length &gt; 12 ? this.files[0].name.substring(0,9)+'...' : this.files[0].name; }" />
</label>
</div>
<div class="pt-2">
<button type="submit" class="btn text-white font-weight-bold px-4 py-2 rounded-lg shadow-sm w-100 w-sm-auto"
th:style="${block.data['theme'] == 'red_gradient' ? 'background:#d7193f;' : (block.data['theme'] == 'teal_gradient' ? 'background:#0d9488;' : (block.data['theme'] == 'light' ? 'background:#1e293b;' : 'background:#2563eb;'))}"
th:text="${block.data['buttonText'] != null and !#strings.isEmpty(block.data['buttonText']) ? block.data['buttonText'] : 'Gửi thông tin'}">Gửi thông tin</button>
</div>
</form>
</div>
</div>
</th:block>
<!-- Mode C: Contact Form -->
<th:block th:if="${block.data['formType'] == 'contact'}">
<div class="p-3 p-sm-4 p-md-5 d-flex align-items-center justify-content-center"
th:style="${'min-height: 100%; border-radius: 20px; background: ' + (block.data['theme'] == 'red_gradient' ? 'linear-gradient(135deg, #701a1a 0%, #d7193f 50%, #fff0f3 100%)' : (block.data['theme'] == 'teal_gradient' ? 'linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #f0fdfa 100%)' : (block.data['theme'] == 'light' ? '#f1f5f9' : 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #eff6ff 100%)')))}">
<div class="bg-white rounded-2xl shadow-2xl w-100 p-4 p-sm-4 p-md-5 my-auto" style="max-width: 580px; border-radius: 16px; box-shadow: 0 20px 40px rgba(0,0,0,0.15);">
<h2 class="h4 font-weight-bold mb-3"
th:style="${'color: ' + (block.data['theme'] == 'red_gradient' ? '#881C1C' : (block.data['theme'] == 'teal_gradient' ? '#0f766e' : (block.data['theme'] == 'light' ? '#1e293b' : '#1e3a8a')))}"
th:text="${block.data['title'] != null and !#strings.isEmpty(block.data['title']) ? block.data['title'] : 'Form Liên hệ & Góp ý'}">Form Liên hệ & Góp ý</h2>
<p class="text-muted small mb-4" th:if="${block.data['subtitle'] != null and !#strings.isEmpty(block.data['subtitle'])}" th:utext="${block.data['subtitle']}"></p>
<form class="sis-contact-form"
th:data-action-url="${block.data['actionUrl']}"
th:data-success-msg="${block.data['successMsg'] != null and !#strings.isEmpty(block.data['successMsg']) ? block.data['successMsg'] : 'Gửi thông tin thành công!'}"
onsubmit="event.preventDefault(); var msg = this.getAttribute('data-success-msg'); alert(msg); this.reset();">
<div class="mb-3">
<label class="form-label font-weight-bold text-secondary small mb-1">Họ và tên <span class="text-danger">*</span></label>
<input type="text" name="fullName" required placeholder="Nhập họ và tên" class="form-control form-control-lg border-gray-300 rounded-lg text-sm" />
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-sm-6">
<label class="form-label font-weight-bold text-secondary small mb-1">Email <span class="text-danger">*</span></label>
<input type="email" name="email" required placeholder="email@example.com" class="form-control form-control-lg border-gray-300 rounded-lg text-sm" />
</div>
<div class="col-12 col-sm-6">
<label class="form-label font-weight-bold text-secondary small mb-1">Số điện thoại</label>
<input type="tel" name="phone" placeholder="Nhập số điện thoại" class="form-control form-control-lg border-gray-300 rounded-lg text-sm" />
</div>
</div>
<div class="mb-3">
<label class="form-label font-weight-bold text-secondary small mb-1">Nội dung tin nhắn <span class="text-danger">*</span></label>
<textarea name="message" rows="4" required placeholder="Nhập nội dung tin nhắn cần hỗ trợ..." class="form-control border-gray-300 rounded-lg text-sm" style="resize:none;"></textarea>
</div>
<div class="pt-2">
<button type="submit" class="btn text-white font-weight-bold px-4 py-2 rounded-lg shadow-sm w-100 w-sm-auto"
th:style="${block.data['theme'] == 'red_gradient' ? 'background:#d7193f;' : (block.data['theme'] == 'teal_gradient' ? 'background:#0d9488;' : (block.data['theme'] == 'light' ? 'background:#1e293b;' : 'background:#2563eb;'))}"
th:text="${block.data['buttonText'] != null and !#strings.isEmpty(block.data['buttonText']) ? block.data['buttonText'] : 'Gửi thông tin'}">Gửi thông tin</button>
</div>
</form>
</div>
</div>
</th:block>
</div>
</th:block>
<!-- Shared Cell Content Renderer Fragment --> <!-- Shared Cell Content Renderer Fragment -->
<th:block th:fragment="renderCell(type, i, blockData)" th:with="cellObj=${(blockData != null and blockData['content'] instanceof T(java.util.Map)) ? blockData['content'] : (blockData != null and blockData['col' + i] instanceof T(java.util.Map) ? blockData['col' + i] : blockData)}"> <th:block th:fragment="renderCell(type, i, blockData)" th:with="cellObj=${(blockData != null and blockData['content'] instanceof T(java.util.Map)) ? blockData['content'] : (blockData != null and blockData['col' + i] instanceof T(java.util.Map) ? blockData['col' + i] : blockData)}">
<!-- Type 1: HTML / Text --> <!-- Type 1: HTML / Text -->
<th:block th:if="${type == null or type == 'html'}"> <th:block th:if="${type == null or type == 'html'}">
<th:block th:if="${blockData != null}"> <th:block th:with="
<th:block th:if="${blockData['content'] != null}"> htmlVal=${cellObj instanceof T(java.util.Map) ? (cellObj['html'] != null ? cellObj['html'] : (cellObj['content'] != null ? cellObj['content'] : '')) : (blockData != null and blockData['col' + i] instanceof T(java.lang.String) ? blockData['col' + i] : (blockData != null and blockData['content'] instanceof T(java.lang.String) ? blockData['content'] : (cellObj instanceof T(java.lang.String) ? cellObj : '')))},
<th:block th:if="${blockData['content'] instanceof T(java.lang.String)}" th:utext="${blockData['content']}"></th:block> hClass=${cellObj instanceof T(java.util.Map) and cellObj['cssClass'] != null ? cellObj['cssClass'] : (cellObj instanceof T(java.util.Map) and cellObj['class'] != null ? cellObj['class'] : '')},
<th:block th:unless="${blockData['content'] instanceof T(java.lang.String)}" th:utext="${blockData['content']['html']}"></th:block> hId=${cellObj instanceof T(java.util.Map) and cellObj['elementId'] != null and !#strings.isEmpty(cellObj['elementId']) ? cellObj['elementId'] : (cellObj instanceof T(java.util.Map) and cellObj['id'] != null and !#strings.isEmpty(cellObj['id']) ? cellObj['id'] : null)},
</th:block> hStyle=${(cellObj instanceof T(java.util.Map) and cellObj['customStyle'] != null ? cellObj['customStyle'] + ';' : '') + (cellObj instanceof T(java.util.Map) and cellObj['style'] != null ? cellObj['style'] + ';' : '')},
<th:block th:unless="${blockData['content'] != null}"> hAttrs=${cellObj instanceof T(java.util.Map) and cellObj['customAttrs'] != null and !#strings.isEmpty(cellObj['customAttrs']) ? cellObj['customAttrs'] : (cellObj instanceof T(java.util.Map) and cellObj['customAttributes'] != null ? cellObj['customAttributes'] : null)}
<th:block th:if="${blockData['col' + i] != null}"> ">
<th:block th:if="${blockData['col' + i] instanceof T(java.lang.String)}" th:utext="${blockData['col' + i]}"></th:block> <div th:class="${#strings.trim(hClass)}"
<th:block th:unless="${blockData['col' + i] instanceof T(java.lang.String)}" th:utext="${blockData['col' + i]['html']}"></th:block> th:id="${hId}"
</th:block> th:style="${#strings.trim(hStyle)}"
</th:block> th:attr="data-custom-attrs=${hAttrs}"
th:remove="${#strings.isEmpty(hClass) and #strings.isEmpty(hId) and #strings.isEmpty(hStyle) and #strings.isEmpty(hAttrs) ? 'tag' : 'none'}">
<div th:utext="${htmlVal}" th:remove="tag"></div>
</div>
</th:block> </th:block>
</th:block> </th:block>
<!-- Type: TinyMCE --> <!-- Type: TinyMCE -->
<th:block th:if="${type == 'tinymce'}"> <th:block th:if="${type == 'tinymce'}">
<th:block th:if="${blockData != null}"> <th:block th:with="
<th:block th:if="${blockData['content'] != null}"> mceVal=${cellObj instanceof T(java.util.Map) ? (cellObj['html'] != null ? cellObj['html'] : (cellObj['content'] != null ? cellObj['content'] : '')) : (blockData != null and blockData['col' + i] instanceof T(java.lang.String) ? blockData['col' + i] : (blockData != null and blockData['content'] instanceof T(java.lang.String) ? blockData['content'] : (cellObj instanceof T(java.lang.String) ? cellObj : '')))},
<th:block th:if="${blockData['content'] instanceof T(java.lang.String)}" th:utext="${blockData['content']}"></th:block> mClass=${(cellObj instanceof T(java.util.Map) and cellObj['cssClass'] != null ? cellObj['cssClass'] : (cellObj instanceof T(java.util.Map) and cellObj['class'] != null ? cellObj['class'] : '')) + (blockData != null and blockData['cssClass'] != null ? ' ' + blockData['cssClass'] : '')},
<th:block th:unless="${blockData['content'] instanceof T(java.lang.String)}" th:utext="${blockData['content']['html']}"></th:block> mId=${cellObj instanceof T(java.util.Map) and cellObj['elementId'] != null and !#strings.isEmpty(cellObj['elementId']) ? cellObj['elementId'] : (cellObj instanceof T(java.util.Map) and cellObj['id'] != null and !#strings.isEmpty(cellObj['id']) ? cellObj['id'] : (blockData != null and blockData['elementId'] != null ? blockData['elementId'] : null))},
</th:block> mStyle=${(cellObj instanceof T(java.util.Map) and cellObj['customStyle'] != null ? cellObj['customStyle'] + ';' : '') + (cellObj instanceof T(java.util.Map) and cellObj['style'] != null ? cellObj['style'] + ';' : '') + (blockData != null and blockData['customStyle'] != null ? blockData['customStyle'] + ';' : '') + (cellObj instanceof T(java.util.Map) and cellObj['alignment'] != null ? 'text-align: ' + cellObj['alignment'] + ';' : '')},
<th:block th:unless="${blockData['content'] != null}"> mAttrs=${cellObj instanceof T(java.util.Map) and cellObj['customAttrs'] != null and !#strings.isEmpty(cellObj['customAttrs']) ? cellObj['customAttrs'] : (cellObj instanceof T(java.util.Map) and cellObj['customAttributes'] != null ? cellObj['customAttributes'] : (blockData != null and blockData['customAttrs'] != null ? blockData['customAttrs'] : null))}
<th:block th:if="${blockData['col' + i] != null}"> ">
<th:block th:if="${blockData['col' + i] instanceof T(java.lang.String)}" th:utext="${blockData['col' + i]}"></th:block> <div th:class="${#strings.trim(mClass)}"
<th:block th:unless="${blockData['col' + i] instanceof T(java.lang.String)}" th:utext="${blockData['col' + i]['html']}"></th:block> th:id="${mId}"
</th:block> th:style="${#strings.trim(mStyle)}"
</th:block> th:attr="data-custom-attrs=${mAttrs}"
th:remove="${#strings.isEmpty(mClass) and #strings.isEmpty(mId) and #strings.isEmpty(mStyle) and #strings.isEmpty(mAttrs) ? 'tag' : 'none'}">
<div th:utext="${mceVal}" th:remove="tag"></div>
</div>
</th:block> </th:block>
</th:block> </th:block>
@@ -1437,6 +1798,113 @@
</details> </details>
</th:block> </th:block>
<!-- Nested Plugin: Form & Tri Ân (googleForm) -->
<th:block th:if="${type == 'googleForm' or type == 'google_form' or type == 'google-form'}">
<div class="sis-google-form-container my-3" th:with="formObj=${cellObj != null ? cellObj : (blockData != null ? blockData['col' + i] : null)}">
<th:block th:if="${formObj != null}">
<th:block th:if="${formObj['formType'] == 'google_iframe'}">
<iframe th:if="${formObj['iframeUrl'] != null and !#strings.isEmpty(formObj['iframeUrl'])}"
th:src="${formObj['iframeUrl']}"
style="width: 100%; border: none; border-radius: 12px; background: #ffffff;"
th:styleappend="${'height: ' + (formObj['iframeHeight'] != null and !#strings.isEmpty(formObj['iframeHeight']) ? formObj['iframeHeight'] : '650px') + ';'}"></iframe>
</th:block>
<th:block th:unless="${formObj['formType'] == 'google_iframe'}">
<div class="p-4 bg-white rounded-xl shadow-md border" style="border-radius:12px;">
<h4 class="font-weight-bold mb-2 text-primary" th:text="${formObj['title'] != null and !#strings.isEmpty(formObj['title']) ? formObj['title'] : 'Form Liên hệ / Tri ân'}">Form</h4>
<p class="text-muted small mb-3" th:if="${formObj['subtitle'] != null and !#strings.isEmpty(formObj['subtitle'])}" th:utext="${formObj['subtitle']}"></p>
<form onsubmit="event.preventDefault(); alert(this.getAttribute('data-success-msg') || 'Gửi thông tin thành công!'); this.reset();" th:data-success-msg="${formObj['successMsg'] != null ? formObj['successMsg'] : 'Gửi thông tin thành công!'}">
<div class="mb-2"><input type="text" name="fullName" required placeholder="Họ và tên" class="form-control form-control-sm" /></div>
<div class="mb-2"><input type="tel" name="phone" required placeholder="Số điện thoại" class="form-control form-control-sm" /></div>
<div class="mb-2"><textarea name="message" rows="3" required placeholder="Nội dung" class="form-control form-control-sm" style="resize:none;"></textarea></div>
<button type="submit" class="btn btn-primary btn-sm font-weight-bold" th:text="${formObj['buttonText'] != null ? formObj['buttonText'] : 'Gửi thông tin'}">Gửi thông tin</button>
</form>
</div>
</th:block>
</th:block>
</div>
</th:block>
<!-- Nested Plugin: Hero Banner -->
<th:block th:if="${type == 'hero'}">
<th:block th:with="heroObj=${cellObj != null ? cellObj : (blockData != null ? blockData['col' + i] : null)}">
<div th:if="${heroObj != null}"
class="sis-hero-banner text-white py-5 px-3 rounded shadow-sm"
th:classappend="${(heroObj['stretched'] == true or heroObj['stretched'] == 'true' ? 'sis-fullwidth-breakout ' : '') + (heroObj['textAlign'] == 'left' ? 'text-left ' : (heroObj['textAlign'] == 'right' ? 'text-right ' : 'text-center ')) + (heroObj['cssClass'] != null ? heroObj['cssClass'] : '')}"
th:id="${heroObj['elementId'] != null ? heroObj['elementId'] : null}"
th:attr="data-custom-attrs=${heroObj['customAttrs']}"
style="
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-size: cover;
background-position: center;
background-color: #002554;
"
th:styleappend="${'min-height: ' + (heroObj['height'] != null and !#strings.isEmpty(heroObj['height']) ? heroObj['height'] : '350px') + ';' + ((heroObj['bgImage'] != null and !#strings.isEmpty(heroObj['bgImage'])) ? 'background-image: url(' + heroObj['bgImage'] + ');' : 'background: linear-gradient(135deg, #002554 0%, #881C1C 100%);') + (heroObj['textAlign'] != null ? '; text-align: ' + heroObj['textAlign'] : '') + (heroObj['customStyle'] != null ? '; ' + heroObj['customStyle'] : '')}"
>
<div class="sis-hero-overlay"
style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 1;"
th:attr="data-type=${heroObj['gradientType'] != null ? heroObj['gradientType'] : 'dark_bottom'},
data-c1=${heroObj['gradientColor1'] != null ? heroObj['gradientColor1'] : '#000000'},
data-c2=${heroObj['gradientColor2'] != null ? heroObj['gradientColor2'] : '#1e3a5f'},
data-opacity=${heroObj['overlayOpacity'] != null ? heroObj['overlayOpacity'] : '0.5'},
data-angle=${heroObj['gradientAngle'] != null ? heroObj['gradientAngle'] : '160'},
data-rx=${heroObj['gradientRadialX'] != null ? heroObj['gradientRadialX'] : '50'},
data-ry=${heroObj['gradientRadialY'] != null ? heroObj['gradientRadialY'] : '50'}">
</div>
<script>
(function() {
var overlay = document.currentScript.previousElementSibling;
if (!overlay || !overlay.classList.contains('sis-hero-overlay')) return;
function hex2rgba(hex, alpha) {
hex = (hex || '#000000').replace('#', '');
if (hex.length === 3) hex = hex.split('').map(function(c) { return c + c; }).join('');
var r = parseInt(hex.substring(0, 2), 16) || 0;
var g = parseInt(hex.substring(2, 4), 16) || 0;
var b = parseInt(hex.substring(4, 6), 16) || 0;
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
}
var type = overlay.getAttribute('data-type') || 'dark_bottom';
var c1 = overlay.getAttribute('data-c1') || '#000000';
var c2 = overlay.getAttribute('data-c2') || '#1e3a5f';
var op = parseFloat(overlay.getAttribute('data-opacity') || '0.5');
var angle = overlay.getAttribute('data-angle') || '160';
var rx = overlay.getAttribute('data-rx') || '50';
var ry = overlay.getAttribute('data-ry') || '50';
var grad = 'rgba(0,0,0,' + op + ')';
if (type === 'none') { grad = 'rgba(0,0,0,' + op + ')'; }
else if (type === 'linear') { grad = 'linear-gradient(' + angle + 'deg, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, 0) + ')'; }
else if (type === 'linear_both' || type === 'custom_color') { grad = 'linear-gradient(' + angle + 'deg, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, op) + ')'; }
else if (type === 'radial') { grad = 'radial-gradient(ellipse at ' + rx + '% ' + ry + '%, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, 0) + ')'; }
else if (type === 'radial_both') { grad = 'radial-gradient(ellipse at ' + rx + '% ' + ry + '%, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, op) + ')'; }
else if (type === 'dark_bottom') { grad = 'linear-gradient(to top, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
else if (type === 'dark_top') { grad = 'linear-gradient(to bottom, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
else if (type === 'dark_left') { grad = 'linear-gradient(to right, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
else if (type === 'dark_right') { grad = 'linear-gradient(to left, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
else if (type === 'spotlight') { grad = 'radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0) 20%, rgba(0,0,0,' + op + ') 80%)'; }
else if (type === 'vignette') { grad = 'radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0) 40%, rgba(0,0,0,' + op + ') 100%)'; }
else if (type === 'duo_blue') { grad = 'linear-gradient(' + angle + 'deg, rgba(2,132,199,' + op + '), rgba(15,23,42,' + op + '))'; }
else if (type === 'duo_purple') { grad = 'linear-gradient(' + angle + 'deg, rgba(126,34,206,' + op + '), rgba(15,23,42,' + op + '))'; }
else if (type === 'duo_sunset') { grad = 'linear-gradient(' + angle + 'deg, rgba(220,38,38,' + op + '), rgba(249,115,22,' + op + '), rgba(234,179,8,' + op + '))'; }
else if (type === 'duo_ocean') { grad = 'linear-gradient(' + angle + 'deg, rgba(6,182,212,' + op + '), rgba(37,99,235,' + op + '))'; }
else if (type === 'duo_forest') { grad = 'linear-gradient(' + angle + 'deg, rgba(22,163,74,' + op + '), rgba(15,23,42,' + op + '))'; }
else if (type === 'duo_rose') { grad = 'linear-gradient(' + angle + 'deg, rgba(244,63,94,' + op + '), rgba(251,191,36,' + op + '))'; }
overlay.style.background = grad;
})();
</script>
<div class="container" style="position: relative; z-index: 2">
<h1 class="display-4 font-weight-bold text-white mb-3" th:if="${heroObj['title'] != null and !#strings.isEmpty(heroObj['title'])}" th:utext="${heroObj['title']}">Hero Banner Title</h1>
<p class="lead text-light mb-4" th:if="${heroObj['subtitle'] != null and !#strings.isEmpty(heroObj['subtitle'])}" th:utext="${heroObj['subtitle']}"></p>
<a th:if="${heroObj['btnText'] != null and !#strings.isEmpty(heroObj['btnText'])}" th:href="${(heroObj['btnLink'] != null and !#strings.isEmpty(heroObj['btnLink'])) ? heroObj['btnLink'] : '#'}" class="btn btn-danger btn-lg font-weight-bold px-4 py-2 shadow" th:utext="${heroObj['btnText']}">Click Here</a>
</div>
</div>
</th:block>
</th:block>
<!-- Nested Plugin: html-snippet --> <!-- Nested Plugin: html-snippet -->
<th:block th:if="${type == 'html-snippet' and ((cellObj != null and cellObj != '' and cellObj['id'] != null) or (blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['id'] != null))}"> <th:block th:if="${type == 'html-snippet' and ((cellObj != null and cellObj != '' and cellObj['id'] != null) or (blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['id'] != null))}">
<div th:replace="~{fragments/snippets :: snippet(id=${cellObj != null and cellObj['id'] != null ? cellObj['id'] : blockData['col' + i]['id']})}"></div> <div th:replace="~{fragments/snippets :: snippet(id=${cellObj != null and cellObj['id'] != null ? cellObj['id'] : blockData['col' + i]['id']})}"></div>
@@ -1444,8 +1912,13 @@
<!-- Nested Plugin: cmsPlugin --> <!-- Nested Plugin: cmsPlugin -->
<th:block th:if="${type == 'cmsPlugin' or type == 'cms-plugin'}"> <th:block th:if="${type == 'cmsPlugin' or type == 'cms-plugin'}">
<th:block th:with="shortcodeVal=${cellObj != null and cellObj['shortcode'] != null ? cellObj['shortcode'] : (cellObj != null and cellObj['id'] != null ? cellObj['id'] : (blockData != null and blockData['col' + i] != null and blockData['col' + i]['shortcode'] != null ? blockData['col' + i]['shortcode'] : (blockData != null and blockData['col' + i] != null ? blockData['col' + i]['id'] : '')))}"> <th:block th:with="cmsObj=${cellObj != null ? cellObj : (blockData != null ? blockData['col' + i] : null)}, shortcodeVal=${cellObj != null and cellObj['shortcode'] != null ? cellObj['shortcode'] : (cellObj != null and cellObj['id'] != null ? cellObj['id'] : (blockData != null and blockData['col' + i] != null and blockData['col' + i]['shortcode'] != null ? blockData['col' + i]['shortcode'] : (blockData != null and blockData['col' + i] != null ? blockData['col' + i]['id'] : '')))}">
<div th:classappend="${cmsObj != null and cmsObj['cssClass'] != null ? cmsObj['cssClass'] : ''}"
th:id="${cmsObj != null and cmsObj['elementId'] != null ? cmsObj['elementId'] : ''}"
th:styleappend="${cmsObj != null and cmsObj['customStyle'] != null ? cmsObj['customStyle'] : ''}"
th:attr="data-custom-attrs=${cmsObj != null and cmsObj['customAttrs'] != null ? cmsObj['customAttrs'] : ''}">
<div th:utext="${hookManager.applyFilters('the_content', shortcodeVal)}" th:remove="tag"></div> <div th:utext="${hookManager.applyFilters('the_content', shortcodeVal)}" th:remove="tag"></div>
</div>
</th:block> </th:block>
</th:block> </th:block>
@@ -1524,6 +1997,38 @@
</th:block> </th:block>
</th:block> </th:block>
<!-- Nested Plugin: cards -->
<th:block th:if="${(type == 'cards' or type == 'practiceCards' or type == 'practice-cards') and ((cellObj != null and cellObj != '' and cellObj['items'] != null) or (blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['items'] != null))}">
<th:block th:with="cObj=${cellObj != null and cellObj['items'] != null ? cellObj : blockData['col' + i]}">
<div th:id="${cObj['globalId'] != null and !#strings.isEmpty(cObj['globalId']) ? cObj['globalId'] : null}"
th:class="${cObj['globalClass'] != null and !#strings.isEmpty(cObj['globalClass']) ? cObj['globalClass'] : 'sis-practice-card-section'}">
<div class="container-fluid p-0">
<div class="row g-4 align-items-stretch">
<th:block th:each="item : ${cObj['items']}">
<div th:if="${item['active'] == null or item['active'] == true}"
th:class="${cObj['cols'] == 1 ? 'col-12 mb-4' : (cObj['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (cObj['cols'] == 3 ? 'col-lg-4 col-md-6 col-12 mb-4' : (cObj['cols'] == 6 ? 'col-lg-2 col-md-4 col-6 mb-4' : 'col-lg-3 col-md-6 col-12 mb-4')))}">
<div class="practice-card">
<div class="card-title-custom" th:utext="${item['title']}">Title</div>
<div class="card-footer-custom">
<span class="date-text" th:if="${item['date'] != null and !#strings.isEmpty(item['date'])}" th:text="${item['date']}">22/06/2026</span>
<a th:href="${item['linkUrl'] != null and !#strings.isEmpty(item['linkUrl']) ? item['linkUrl'] : '#'}"
th:target="${item['linkTarget'] != null ? item['linkTarget'] : '_self'}"
class="action-link">
<span th:text="${item['linkText'] != null and !#strings.isEmpty(item['linkText']) ? item['linkText'] : 'Tìm hiểu thêm'}">Tìm hiểu thêm</span>
<div class="icon-circle">
<i class="fas fa-chevron-right"></i>
</div>
</a>
</div>
</div>
</div>
</th:block>
</div>
</div>
</div>
</th:block>
</th:block>
<!-- Nested Plugin: hovercard --> <!-- Nested Plugin: hovercard -->
<th:block th:if="${(type == 'hovercard' or type == 'hover-card' or type == 'hoverCard') and ((cellObj != null and cellObj != '' and cellObj['items'] != null) or (blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['items'] != null))}"> <th:block th:if="${(type == 'hovercard' or type == 'hover-card' or type == 'hoverCard') and ((cellObj != null and cellObj != '' and cellObj['items'] != null) or (blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['items'] != null))}">
<th:block th:with="hcObj=${cellObj != null and cellObj['items'] != null ? cellObj : blockData['col' + i]}"> <th:block th:with="hcObj=${cellObj != null and cellObj['items'] != null ? cellObj : blockData['col' + i]}">
@@ -0,0 +1,353 @@
<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'">Quản lý Google Form 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;
}
.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ý Google Form & Form Tương Tác</h1>
<div>
<button type="button" class="btn btn-success font-weight-bold" onclick="openAddModal()">
<i class="fas fa-plus-circle mr-1"></i> Thêm Form Mớ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/google-form}" id="mainGoogleForm">
<input type="hidden" name="formData" id="formData" />
<!-- Forms List View (Read-Only List with Modal Edit) -->
<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="fab fa-google mr-2"></i> Danh sách các Form trong hệ thống</h6>
<button type="submit" class="btn btn-primary btn-sm font-weight-bold px-3" onclick="return saveAllForms();">
<i class="fas fa-save mr-1"></i> Lưu Cấu Hình
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered table-hover mb-0" id="mainFormsList">
<thead class="thead-light">
<tr>
<th style="width: 50px" class="text-center">STT</th>
<th style="width: 140px">ID (Slug)</th>
<th>Tên Form</th>
<th style="width: 160px">Loại Form</th>
<th style="width: 130px">Giao diện (Theme)</th>
<th style="width: 320px">Shortcode nhúng</th>
<th style="width: 160px" class="text-center">Thao tác</th>
</tr>
</thead>
<tbody id="formsTableBody">
<!-- Populated dynamically by JS -->
</tbody>
</table>
</div>
</div>
</div>
</form>
<!-- Instruction Alert -->
<div class="card border-left-info shadow py-2 mb-4">
<div class="card-body">
<h6 class="font-weight-bold text-info"><i class="fas fa-info-circle mr-1"></i> Hướng dẫn sử dụng Google Form Shortcode</h6>
<p class="small text-muted mb-0">
Dùng shortcode <code class="shortcode-badge">[plugin:google-form id="tri-an"]</code> dán vào nội dung bài viết, trang tĩnh hoặc Editor.js HTML Snippet block để hiển thị Form tương ứng.<br/>
Dữ liệu từ Google Form Embed được lưu trữ trực tiếp trên Google Sheets / Google Drive của bạn.
</p>
</div>
</div>
</div>
<!-- Add/Edit Form Modal -->
<div class="modal fade" id="formModal" tabindex="-1" role="dialog" aria-labelledby="formModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title font-weight-bold" id="formModalLabel">Cấu hình Form</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<input type="hidden" id="modalFormIndex" value="-1" />
<div class="row">
<div class="col-md-6 form-group">
<label class="font-weight-bold text-gray-700">Mã ID (Slug Shortcode) <span class="text-danger">*</span></label>
<input type="text" id="modalId" class="form-control" placeholder="Ví dụ: tri-an, contact-1" />
<small class="form-text text-muted">Mã duy nhất dùng trong shortcode <code>[plugin:google-form id="..."]</code></small>
</div>
<div class="col-md-6 form-group">
<label class="font-weight-bold text-gray-700">Tên Quản lý Form <span class="text-danger">*</span></label>
<input type="text" id="modalName" class="form-control" placeholder="Ví dụ: Form Tri Ân Bệnh Viện ĐHYD" />
</div>
</div>
<div class="row">
<div class="col-md-6 form-group">
<label class="font-weight-bold text-gray-700">Loại Form</label>
<select id="modalFormType" class="form-control" onchange="toggleFormTypeFields()">
<option value="tri_an">Form Chia Sẻ Lời Tri Ân (Tương tác có Upload ảnh)</option>
<option value="contact">Form Liên Hệ & Góp Ý</option>
<option value="google_iframe">Google Form (IFrame Embed Link)</option>
</select>
</div>
<div class="col-md-6 form-group">
<label class="font-weight-bold text-gray-700">Giao diện (Theme)</label>
<select id="modalTheme" class="form-control">
<option value="blue_gradient">Xanh Dương Gradient (ĐHYD HCM style)</option>
<option value="red_gradient">Đỏ Gradient (S.I.S style)</option>
<option value="teal_gradient">Xanh Ngọc Gradient</option>
<option value="light">Sáng Tối Giản (Minimal Light)</option>
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 form-group">
<label class="font-weight-bold text-gray-700">Tiêu đề Form (Title)</label>
<input type="text" id="modalTitle" class="form-control" placeholder="Ví dụ: Chia sẻ lời tri ân" />
</div>
<div class="col-md-6 form-group">
<label class="font-weight-bold text-gray-700">Tiêu đề phụ (Subtitle)</label>
<input type="text" id="modalSubtitle" class="form-control" placeholder="Ví dụ: Gửi lời cảm ơn chân thành..." />
</div>
</div>
<!-- Google Form Embed URL (shown when formType is google_iframe) -->
<div id="googleIframeFields" style="display: none;">
<div class="form-group">
<label class="font-weight-bold text-primary"><i class="fab fa-google mr-1"></i> Google Form Embed URL <span class="text-danger">*</span></label>
<input type="url" id="modalEmbedUrl" class="form-control" placeholder="https://docs.google.com/forms/d/e/.../viewform?embedded=true" />
<small class="form-text text-muted">Lấy từ Google Form -> Gửi -> Nhúng HTML -> Sao chép URL trong thẻ <code>src="..."</code></small>
</div>
<div class="form-group">
<label class="font-weight-bold text-gray-700">Chiều cao iFrame (Height)</label>
<input type="text" id="modalHeight" class="form-control" placeholder="650px" />
</div>
</div>
<!-- Custom Form Settings -->
<div id="customFormFields">
<div class="row">
<div class="col-md-6 form-group">
<label class="font-weight-bold text-gray-700">Tên Nút Nộp (Button Text)</label>
<input type="text" id="modalButtonText" class="form-control" placeholder="Gửi thông tin" />
</div>
<div class="col-md-6 form-group">
<label class="font-weight-bold text-gray-700">Thông báo Thành Công (Success Alert)</label>
<input type="text" id="modalSuccessMsg" class="form-control" placeholder="Gửi thông tin thành công!" />
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Hủy</button>
<button type="button" class="btn btn-primary font-weight-bold" onclick="saveModalForm()">Lưu Form</button>
</div>
</div>
</div>
</div>
<script th:inline="javascript">
/*<![CDATA[*/
var formsData = [];
try {
var rawJson = /*[[${formsJson}]]*/ '[]';
formsData = JSON.parse(rawJson || '[]');
} catch (e) {
formsData = [];
}
document.addEventListener('DOMContentLoaded', function () {
renderFormsTable();
});
function renderFormsTable() {
var tbody = document.getElementById('formsTableBody');
tbody.innerHTML = '';
if (!formsData || formsData.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted">Chưa có Form nào. Bấm <strong>Thêm Form Mới</strong> để tạo.</td></tr>';
return;
}
formsData.forEach(function (form, index) {
var tr = document.createElement('tr');
var fTypeLabel = form.formType === 'google_iframe' ? '<span class="badge badge-danger">Google Form iFrame</span>' :
form.formType === 'contact' ? '<span class="badge badge-info">Form Liên Hệ</span>' :
'<span class="badge badge-success">Form Tri Ân</span>';
var themeLabel = form.theme === 'red_gradient' ? 'Đỏ S.I.S' :
form.theme === 'teal_gradient' ? 'Xanh Ngọc' :
form.theme === 'light' ? 'Sáng Minimal' : 'Xanh ĐHYD';
var shortcodeText = '[plugin:google-form id="' + (form.id || '') + '"]';
tr.innerHTML =
'<td class="text-center align-middle font-weight-bold">' + (index + 1) + '</td>' +
'<td class="align-middle font-weight-bold text-primary"><code>' + escapeHtml(form.id || '') + '</code></td>' +
'<td class="align-middle font-weight-bold">' + escapeHtml(form.name || form.title || 'Form ' + (index+1)) + '</td>' +
'<td class="align-middle">' + fTypeLabel + '</td>' +
'<td class="align-middle"><small class="text-muted">' + themeLabel + '</small></td>' +
'<td class="align-middle">' +
'<div class="d-flex align-items-center justify-content-between">' +
'<span class="shortcode-badge" id="sc-' + index + '">' + escapeHtml(shortcodeText) + '</span>' +
'<button type="button" class="btn btn-outline-secondary btn-sm copy-btn ml-2" onclick="copyShortcode(\'' + escapeHtml(shortcodeText) + '\')"><i class="far fa-copy"></i></button>' +
'</div>' +
'</td>' +
'<td class="text-center align-middle">' +
'<button type="button" class="btn btn-sm btn-info mr-1" onclick="openEditModal(' + index + ')"><i class="fas fa-edit"></i> Sửa</button>' +
'<button type="button" class="btn btn-sm btn-danger" onclick="deleteForm(' + index + ')"><i class="fas fa-trash"></i> Xóa</button>' +
'</td>';
tbody.appendChild(tr);
});
}
function toggleFormTypeFields() {
var fType = document.getElementById('modalFormType').value;
var iframeGroup = document.getElementById('googleIframeFields');
var customGroup = document.getElementById('customFormFields');
if (fType === 'google_iframe') {
iframeGroup.style.display = 'block';
} else {
iframeGroup.style.display = 'none';
}
}
function openAddModal() {
document.getElementById('modalFormIndex').value = '-1';
document.getElementById('formModalLabel').textContent = 'Thêm Form Mới';
document.getElementById('modalId').value = 'form-' + (formsData.length + 1);
document.getElementById('modalName').value = '';
document.getElementById('modalTitle').value = 'Chia sẻ lời tri ân';
document.getElementById('modalSubtitle').value = '';
document.getElementById('modalFormType').value = 'tri_an';
document.getElementById('modalTheme').value = 'blue_gradient';
document.getElementById('modalEmbedUrl').value = '';
document.getElementById('modalHeight').value = '650px';
document.getElementById('modalButtonText').value = 'Gửi thông tin';
document.getElementById('modalSuccessMsg').value = 'Gửi thông tin thành công!';
toggleFormTypeFields();
$('#formModal').modal('show');
}
function openEditModal(index) {
var form = formsData[index];
if (!form) return;
document.getElementById('modalFormIndex').value = index;
document.getElementById('formModalLabel').textContent = 'Chỉnh sửa Form: ' + (form.name || form.id);
document.getElementById('modalId').value = form.id || '';
document.getElementById('modalName').value = form.name || '';
document.getElementById('modalTitle').value = form.title || '';
document.getElementById('modalSubtitle').value = form.subtitle || '';
document.getElementById('modalFormType').value = form.formType || 'tri_an';
document.getElementById('modalTheme').value = form.theme || 'blue_gradient';
document.getElementById('modalEmbedUrl').value = form.embedUrl || '';
document.getElementById('modalHeight').value = form.height || '650px';
document.getElementById('modalButtonText').value = form.buttonText || 'Gửi thông tin';
document.getElementById('modalSuccessMsg').value = form.successMsg || 'Gửi thông tin thành công!';
toggleFormTypeFields();
$('#formModal').modal('show');
}
function saveModalForm() {
var index = parseInt(document.getElementById('modalFormIndex').value, 10);
var id = document.getElementById('modalId').value.trim();
var name = document.getElementById('modalName').value.trim();
if (!id) {
alert('Vui lòng nhập Mã ID (Slug)');
return;
}
if (!name) name = id;
var item = {
id: id,
name: name,
title: document.getElementById('modalTitle').value.trim(),
subtitle: document.getElementById('modalSubtitle').value.trim(),
formType: document.getElementById('modalFormType').value,
theme: document.getElementById('modalTheme').value,
embedUrl: document.getElementById('modalEmbedUrl').value.trim(),
height: document.getElementById('modalHeight').value.trim() || '650px',
buttonText: document.getElementById('modalButtonText').value.trim() || 'Gửi thông tin',
successMsg: document.getElementById('modalSuccessMsg').value.trim() || 'Gửi thông tin thành công!'
};
if (index >= 0 && index < formsData.length) {
formsData[index] = item;
} else {
formsData.push(item);
}
renderFormsTable();
$('#formModal').modal('hide');
}
function deleteForm(index) {
if (confirm('Bạn có chắc chắn muốn xóa Form này?')) {
formsData.splice(index, 1);
renderFormsTable();
}
}
function saveAllForms() {
document.getElementById('formData').value = JSON.stringify(formsData);
return true;
}
function copyShortcode(text) {
navigator.clipboard.writeText(text).then(function() {
alert('Đã sao chép shortcode: ' + text);
});
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/*]]>*/
</script>
</div>
</body>
</html>
@@ -21,7 +21,7 @@
<th:block th:utext="${hookManager.doActionAndReturn('wp_head')}"></th:block> <th:block th:utext="${hookManager.doActionAndReturn('wp_head')}"></th:block>
</head> </head>
<body <body
th:class="${(bodyClass != null ? bodyClass : '') + ((page != null and page.pageType != null and page.pageType.name() == 'HOME') ? ' umass-platform-homepage path-frontpage page-node-type-homepage homepage transparent-header' : '')}" th:class="${(bodyClass != null ? bodyClass : '') + ((page != null and page.pageType != null and page.pageType.name() == 'HOME') ? ' umass-platform-homepage path-frontpage page-node-type-homepage homepage transparent-header' : '') + ((page != null and page.slug != null and !#strings.isEmpty(page.slug) and (bodyClass == null or !#strings.contains(bodyClass, page.slug))) ? (' ' + page.slug + ' page-' + page.slug + ' page-slug-' + page.slug) : '')}"
> >
<div class="dialog-off-canvas-main-canvas" data-off-canvas-main-canvas=""> <div class="dialog-off-canvas-main-canvas" data-off-canvas-main-canvas="">
<!-- Include Header Fragment --> <!-- Include Header Fragment -->
@@ -207,16 +207,21 @@
} }
.charity-hero .photo-card { .charity-hero .photo-card {
/* Anchored at origin — position driven entirely by transform (GPU-composited) */
position: absolute; position: absolute;
left: var(--x); left: 0;
top: var(--y); top: 0;
width: var(--w); /* Fixed base size — scale() handles apparent width without layout recalc */
width: 400px;
background: #fff; background: #fff;
border: 4px solid var(--red); border: 4px solid var(--red);
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
box-shadow: 0 14px 36px rgba(61, 26, 34, 0.22); box-shadow: 0 14px 36px rgba(61, 26, 34, 0.22);
transform: translate3d(0, 0, 0) rotate(var(--r)) scale(0.92); /* translate → stage-relative position, rotate → tilt, scale → apparent size — all GPU-composited */
/* --sw/--sh set by JS (ResizeObserver): convert --x/--y % to px so translate uses stage coords, not card coords */
transform: translate(calc(var(--x) * var(--sw, 700px) / 100), calc(var(--y) * var(--sh, 430px) / 100)) rotate(var(--r))
scale(calc(var(--s, 0.5) * 0.92));
opacity: 0; opacity: 0;
z-index: 5; z-index: 5;
will-change: transform, opacity; will-change: transform, opacity;
@@ -231,6 +236,19 @@
transform 3s cubic-bezier(0.2, 0.8, 0.2, 1); transform 3s cubic-bezier(0.2, 0.8, 0.2, 1);
} }
/* Suppress paint-heavy properties while the transform transition is running.
box-shadow and filter both require a separate GPU composite pass; disabling
them during the 3s card transition eliminates per-frame repaint cost. */
.charity-hero .gallery-stage.is-transitioning .photo-card {
box-shadow: none;
filter: none;
/* Override: remove box-shadow & filter from transition so they snap to none instantly */
transition:
opacity 3s ease-in-out,
border-color 3s cubic-bezier(0.2, 0.8, 0.2, 1),
transform 3s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.charity-hero .photo-card img { .charity-hero .photo-card img {
display: block; display: block;
width: 100%; width: 100%;
@@ -257,10 +275,9 @@
} }
.charity-hero .photo-card.is-active { .charity-hero .photo-card.is-active {
left: 50%; /* Centre in stage: shift to (stageW - cardW) / 2 horizontally, (stageH - cardH) / 2 vertically */
top: 50%; /* Card is 400×300px base; --sw/--sh are stage pixel dims set by JS */
width: min(480px, 58vw); transform: translate(calc((var(--sw, 700px) - 400px) / 2), calc((var(--sh, 430px) - 300px) / 2)) rotate(0deg) scale(1.18) !important;
transform: translate(-50%, -50%) rotate(0deg) scale(1.06) !important;
opacity: 1; opacity: 1;
z-index: 30; z-index: 30;
border-color: #fff; border-color: #fff;
@@ -277,7 +294,8 @@
.charity-hero .photo-card.is-dim { .charity-hero .photo-card.is-dim {
opacity: 0.28; opacity: 0.28;
filter: grayscale(0.08) saturate(0.8) blur(0.2px); filter: grayscale(0.08) saturate(0.8) blur(0.2px);
transform: translate3d(0, 0, 0) rotate(var(--r)) scale(0.82); transform: translate(calc(var(--x) * var(--sw, 700px) / 100), calc(var(--y) * var(--sh, 430px) / 100)) rotate(var(--r))
scale(calc(var(--s, 0.5) * 0.82));
z-index: 2; z-index: 2;
} }
@@ -288,7 +306,8 @@
.charity-hero .photo-card.is-exit { .charity-hero .photo-card.is-exit {
opacity: 0; opacity: 0;
transform: translateY(28px) scale(0.74) rotate(var(--r)); transform: translate(calc(var(--x) * var(--sw, 700px) / 100), calc(var(--y) * var(--sh, 430px) / 100 + 28px)) rotate(var(--r))
scale(calc(var(--s, 0.5) * 0.74));
} }
.charity-hero .gallery-controls { .charity-hero .gallery-controls {
@@ -512,11 +531,13 @@
@keyframes cardIntro { @keyframes cardIntro {
from { from {
opacity: 0; opacity: 0;
transform: translate3d(-28px, 22px, 0) rotate(calc(var(--r) - 7deg)) scale(0.78); transform: translate(calc(var(--x) * var(--sw, 700px) / 100 - 28px), calc(var(--y) * var(--sh, 430px) / 100 + 22px))
rotate(calc(var(--r) - 7deg)) scale(calc(var(--s, 0.5) * 0.78));
} }
to { to {
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0) rotate(var(--r)) scale(0.92); transform: translate(calc(var(--x) * var(--sw, 700px) / 100), calc(var(--y) * var(--sh, 430px) / 100)) rotate(var(--r))
scale(calc(var(--s, 0.5) * 0.92));
} }
} }
/* Removed microFloat because animating margin-top constantly recalculates layout causing major stutter */ /* Removed microFloat because animating margin-top constantly recalculates layout causing major stutter */
@@ -622,10 +643,12 @@
overflow: hidden; overflow: hidden;
} }
.charity-hero .photo-card { .charity-hero .photo-card {
width: calc(var(--w) * 0.72); /* Smaller screens: scale down via transform multiplier — zero layout reflow */
transform: translate(calc(var(--x) * var(--sw, 370px) / 100), calc(var(--y) * var(--sh, 370px) / 100)) rotate(var(--r))
scale(calc(var(--s, 0.5) * 0.92 * 0.72));
} }
.charity-hero .photo-card.is-active { .charity-hero .photo-card.is-active {
width: min(86vw, 380px); transform: translate(calc((var(--sw, 370px) - 400px) / 2), calc((var(--sh, 370px) - 300px) / 2)) rotate(0deg) scale(0.86) !important;
} }
.charity-hero .content-panel { .charity-hero .content-panel {
padding: 8px; padding: 8px;
@@ -676,32 +699,32 @@
</div> </div>
<div class="gallery-stage" id="galleryStage"> <div class="gallery-stage" id="galleryStage">
<article class="photo-card p1" data-index="0" style="--x: -4%; --y: 2%; --r: -2.5deg; --w: 205px; --delay: 0.05s"> <article class="photo-card p1" data-index="0" style="--x: -4; --y: 2; --r: -2.5deg; --w: 205px; --delay: 0.05s">
<img src="/upload/charity_v4/photo-01.jpg" alt="Trao hỗ trợ bệnh nhân 1" /> <img src="/uploads/2026/08/photo-01.jpg" alt="Trao hỗ trợ bệnh nhân 1" />
</article> </article>
<article class="photo-card p2" data-index="1" style="--x: 18%; --y: -2%; --r: 1.6deg; --w: 298px; --delay: 0.16s"> <article class="photo-card p2" data-index="1" style="--x: 18; --y: -2; --r: 1.6deg; --w: 298px; --delay: 0.16s">
<img src="/upload/charity_v4/photo-02.jpg" alt="Trao hỗ trợ bệnh nhân 2" /> <img src="/uploads/2026/08/photo-02.jpg" alt="Trao hỗ trợ bệnh nhân 2" />
</article> </article>
<article class="photo-card p3" data-index="2" style="--x: 63%; --y: 0%; --r: -1.2deg; --w: 410px; --delay: 0.27s"> <article class="photo-card p3" data-index="2" style="--x: 63; --y: 0; --r: -1.2deg; --w: 410px; --delay: 0.27s">
<img src="/upload/charity_v4/photo-05.jpg" alt="Trao hỗ trợ bệnh nhân 3" /> <img src="/upload/charity_v4/photo-05.jpg" alt="Trao hỗ trợ bệnh nhân 3" />
</article> </article>
<article class="photo-card p4" data-index="3" style="--x: 0%; --y: 27%; --r: 2.2deg; --w: 245px; --delay: 0.38s"> <article class="photo-card p4" data-index="3" style="--x: 0; --y: 27; --r: 2.2deg; --w: 245px; --delay: 0.38s">
<img src="/upload/charity_v4/photo-03.jpg" alt="Trao hỗ trợ bệnh nhân 4" /> <img src="/uploads/2026/08/photo-03.jpg" alt="Trao hỗ trợ bệnh nhân 4" />
</article> </article>
<article class="photo-card p5" data-index="4" style="--x: 32%; --y: 32%; --r: -1.8deg; --w: 260px; --delay: 0.49s"> <article class="photo-card p5" data-index="4" style="--x: 32; --y: 32; --r: -1.8deg; --w: 260px; --delay: 0.49s">
<img src="/upload/charity_v4/photo-04.jpg" alt="Trao hỗ trợ bệnh nhân 5" /> <img src="/uploads/2026/08/photo-04.jpg" alt="Trao hỗ trợ bệnh nhân 5" />
</article> </article>
<article class="photo-card p6" data-index="5" style="--x: 57%; --y: 35%; --r: 1deg; --w: 195px; --delay: 0.6s"> <article class="photo-card p6" data-index="5" style="--x: 57; --y: 35; --r: 1deg; --w: 195px; --delay: 0.6s">
<img src="/upload/charity_v4/photo-06.jpg" alt="Trao hỗ trợ bệnh nhân 6" /> <img src="/uploads/2026/08/photo-06.jpg" alt="Trao hỗ trợ bệnh nhân 6" />
</article> </article>
<article class="photo-card p7" data-index="6" style="--x: 4%; --y: 62%; --r: -1.4deg; --w: 270px; --delay: 0.71s"> <article class="photo-card p7" data-index="6" style="--x: 4; --y: 62; --r: -1.4deg; --w: 270px; --delay: 0.71s">
<img src="/upload/charity_v4/photo-07.jpg" alt="Trao hỗ trợ bệnh nhân 7" /> <img src="/uploads/2026/08/photo-07.jpg" alt="Trao hỗ trợ bệnh nhân 7" />
</article> </article>
<article class="photo-card p8" data-index="7" style="--x: 42%; --y: 73%; --r: 1.2deg; --w: 255px; --delay: 0.82s"> <article class="photo-card p8" data-index="7" style="--x: 42; --y: 73; --r: 1.2deg; --w: 255px; --delay: 0.82s">
<img src="/upload/charity_v4/photo-08.jpg" alt="Trao hỗ trợ bệnh nhân 8" /> <img src="/uploads/2026/08/photo-08.jpg" alt="Trao hỗ trợ bệnh nhân 8" />
</article> </article>
<article class="photo-card p9" data-index="8" style="--x: 77%; --y: 66%; --r: -1deg; --w: 338px; --delay: 0.93s"> <article class="photo-card p9" data-index="8" style="--x: 77; --y: 66; --r: -1deg; --w: 338px; --delay: 0.93s">
<img src="/upload/charity_v4/photo-09.jpg" alt="Trao hỗ trợ bệnh nhân 9" /> <img src="/uploads/2026/08/photo-09.jpg" alt="Trao hỗ trợ bệnh nhân 9" />
</article> </article>
<div class="gallery-light" aria-hidden="true"></div> <div class="gallery-light" aria-hidden="true"></div>
</div> </div>
@@ -751,6 +774,40 @@
const dots = Array.from(container.querySelectorAll('.dot')); const dots = Array.from(container.querySelectorAll('.dot'));
if (!stage || cards.length === 0) return; if (!stage || cards.length === 0) return;
// ── GPU Layout Vars ──────────────────────────────────────────────────────
// Inject --sw / --sh (stage pixel dimensions) onto the stage so CSS calc()
// can convert the --x/--y percentage vars into stage-relative pixel offsets.
// Also compute --s (scale factor) from --w for each card once, eliminating
// the need for width: var(--w) which triggers layout on every state change.
const BASE_CARD_W = 400; // matches fixed width: 400px in CSS
function syncStageVars() {
const sw = stage.offsetWidth;
const sh = stage.offsetHeight;
stage.style.setProperty('--sw', sw + 'px');
stage.style.setProperty('--sh', sh + 'px');
// Propagate to cards (cards inherit from stage via CSS cascade)
}
// Compute --s for each card from its --w custom property once at init
cards.forEach(card => {
const wStr = getComputedStyle(card).getPropertyValue('--w').trim();
const wPx = parseFloat(wStr);
if (!isNaN(wPx) && wPx > 0) {
card.style.setProperty('--s', (wPx / BASE_CARD_W).toFixed(4));
}
});
syncStageVars();
// Keep stage vars current as viewport resizes (cheap: only on resize)
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(syncStageVars).observe(stage);
} else {
window.addEventListener('resize', syncStageVars);
}
// ── Carousel State Machine ───────────────────────────────────────────────
let active = 0; let active = 0;
let timer = null; let timer = null;
const duration = 4500; /* Tăng thời gian chờ (autoplay) từ 2.8 giây lên 4.5 giây để nhìn rõ ảnh hơn */ const duration = 4500; /* Tăng thời gian chờ (autoplay) từ 2.8 giây lên 4.5 giây để nhìn rõ ảnh hơn */
@@ -774,23 +831,24 @@
}); });
dots.forEach((dot, i) => dot.classList.toggle('is-active', i === active)); dots.forEach((dot, i) => dot.classList.toggle('is-active', i === active));
// Suppress box-shadow & filter for the duration of the transform transition
stage.classList.add('is-transitioning');
clearTimeout(stage._transitionTimer);
stage._transitionTimer = setTimeout(() => stage.classList.remove('is-transitioning'), 3000);
} }
function next() { function next() {
setActive(active + 1); setActive(active + 1);
} }
function start() { function start() {
stop(); stop();
timer = setInterval(next, duration); timer = setInterval(next, duration);
} }
function stop() { function stop() {
if (timer) clearInterval(timer); if (timer) clearInterval(timer);
} }
// Hệ thống sẽ chạy tự động 100%
dots.forEach((dot, i) => { dots.forEach((dot, i) => {
dot.addEventListener('click', () => { dot.addEventListener('click', () => {
setActive(i); setActive(i);
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.30.8/dist/editorjs.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js"></script>
<style>
.ce-block { margin: 20px; border: 1px solid #ccc; padding: 10px; }
</style>
</head>
<body>
<div id="editorjs"></div>
<script>
class TinyMCETool {
static get toolbox() { return { title: 'TinyMCE', icon: 'T' }; }
render() {
const container = document.createElement('div');
const textarea = document.createElement('textarea');
container.appendChild(textarea);
setTimeout(() => {
tinymce.init({
target: textarea,
setup: function(editor) {
editor.on('focusin mousedown click keyup', function() {
container.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
container.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true }));
console.log('Dispatched mousedown & mousemove');
});
}
});
}, 100);
return container;
}
save() { return { html: '' }; }
}
const editor = new EditorJS({
holder: 'editorjs',
tools: { tiny: TinyMCETool },
data: {
blocks: [
{ type: 'paragraph', data: { text: 'Block 1' } },
{ type: 'tiny', data: {} },
{ type: 'paragraph', data: { text: 'Block 3' } }
]
}
});
</script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.30.8/dist/editorjs.umd.js"></script>
</head>
<body>
<div id="editorjs"></div>
<script>
class CustomTool {
static get toolbox() { return { title: 'Custom', icon: 'C' }; }
constructor({api}) { this.api = api; }
render() {
const div = document.createElement('div');
div.innerHTML = '<input type="text" placeholder="Click me">';
div.addEventListener('click', () => {
const count = this.api.blocks.getBlocksCount();
for(let i=0; i<count; i++) {
const b = this.api.blocks.getBlockByIndex(i);
if (b.holder.contains(div)) {
console.log("Setting to block", i);
this.api.caret.setToBlock(i);
break;
}
}
});
return div;
}
save() { return {}; }
}
const editor = new EditorJS({
holder: 'editorjs',
tools: { custom: CustomTool },
data: { blocks: [ { type: 'paragraph', data: { text: 'Block 1' } }, { type: 'custom', data: {} } ] }
});
</script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html><body>
<div id="wrapper">
<textarea id="mytext"></textarea>
</div>
<script src="https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js"></script>
<script>
document.getElementById('wrapper').addEventListener('mousedown', function(e) {
console.log('wrapper mousedown received!', e.isTrusted, e.clientX, e.clientY);
});
tinymce.init({
selector: '#mytext',
setup: function(editor) {
editor.on('focusin mousedown click keyup', function(e) {
console.log('tinymce event:', e.type);
const target = document.getElementById('wrapper');
target.dispatchEvent(new MouseEvent('mousedown', {
bubbles: true, cancelable: true, view: window, clientX: 10, clientY: 10
}));
});
}
});
</script>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B