diff --git a/BV_DHYD_HCM/temp.html b/BV_DHYD_HCM/temp.html index b0324f01..939b7ad8 100644 --- a/BV_DHYD_HCM/temp.html +++ b/BV_DHYD_HCM/temp.html @@ -3,278 +3,164 @@ - Trang Tin Tức - Bài Viết Nổi Bật + Chia sẻ lời tri ân + + - -
- -
-
- -
- - -
- Tuần lễ thế giới nuôi con bằng sữa mẹ -
-
- Sự kiện -
-

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

- -
+ +
+ + +
- - -
- Đau lưng ở nhân viên văn phòng -
-
- Tin tức -
-

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

- -
+ +
+ + +
- - -
- Bệnh lý miễn dịch thần kinh -
-
- Tin tức -
-

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

- -
+ +
+ + +
- - -
- Phẫu thuật nội soi -
-
- Tin tức -
-

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

- -
-
-
+ +
+ +
+ + diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/SecurityConfiguration.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/SecurityConfiguration.java index 5a1a6c60..a9765724 100644 --- a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/SecurityConfiguration.java +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/SecurityConfiguration.java @@ -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.web.OAuth2AuthorizationRequestRedirectFilter; import org.springframework.security.web.SecurityFilterChain; +import jakarta.servlet.http.HttpServletResponse; import com.sisvietnamvn.web.security.AuthoritiesConstants; import com.sisvietnamvn.web.security.CustomAuthenticationSuccessHandler; @@ -105,7 +106,40 @@ public class SecurityConfiguration { .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())); if (env.acceptsProfiles(Profiles.of(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT))) { http.authorizeHttpRequests(authz -> authz.requestMatchers("/h2-console/**").permitAll()) diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/PageController.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/PageController.java index 3de28fc2..da48b534 100644 --- a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/PageController.java +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/PageController.java @@ -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("blocks", blocks); 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 boolean hasPostsBlock = blocks.stream().anyMatch(b -> "posts".equals(b.get("type"))); diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/googleForm/googleFormAdminController.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/googleForm/googleFormAdminController.java new file mode 100644 index 00000000..475dfe53 --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/googleForm/googleFormAdminController.java @@ -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 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>() {}); + 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 parseFormsJson(String json) { + if (json == null || json.trim().isEmpty() || "[]".equals(json)) { + return new ArrayList<>(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + return new ArrayList<>(); + } + } + + public static class 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; } + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/googleForm/googleFormPlugin.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/googleForm/googleFormPlugin.java new file mode 100644 index 00000000..26d87ee9 --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/plugins/googleForm/googleFormPlugin.java @@ -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 += "Google Form & Tri Ân\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 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 "

Vui lòng nhập Google Form Embed URL.

"; + } + String bgStyle = getThemeBackground(theme); + StringBuilder sb = new StringBuilder(); + sb.append("
"); + sb.append(" "); + sb.append("
"); + 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("
"); + sb.append("
"); + sb.append("

").append(escapeHtml(title)).append("

"); + if (!subtitle.isEmpty()) { + sb.append("

").append(escapeHtml(subtitle)).append("

"); + } + sb.append("
"); + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + 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("
"); + sb.append("
"); + sb.append("

").append(escapeHtml(title)).append("

"); + if (!subtitle.isEmpty()) { + sb.append("

").append(escapeHtml(subtitle)).append("

"); + } + sb.append("
"); + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + 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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } +} diff --git a/sisvietnamvn_main/src/main/resources/static/css/custom.css b/sisvietnamvn_main/src/main/resources/static/css/custom.css index c2168f53..74e60abe 100644 --- a/sisvietnamvn_main/src/main/resources/static/css/custom.css +++ b/sisvietnamvn_main/src/main/resources/static/css/custom.css @@ -493,9 +493,7 @@ figure.table table tr:hover { .flex { display: flex !important; } -.education .flex .h-stretch { - height: stretch; -} + .flex-col { flex-direction: column; } @@ -614,6 +612,54 @@ figure.table table tr:hover { 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 ========================================================================== */ @@ -816,6 +862,21 @@ figure.table table tr:hover { #wrapperCrowdDoctors { 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) --- */ @@ -2167,3 +2228,185 @@ img[width='100px'] { .sis-recruiting-detail-btn { 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); +} + + diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js index 1a606877..47c5a943 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js @@ -433,6 +433,12 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler = if (typeof SISHeroBannerTool !== 'undefined') { builtInTools.hero = { class: SISHeroBannerTool }; } + if (typeof SISGoogleFormTool !== 'undefined') { + builtInTools.googleForm = { class: SISGoogleFormTool }; + } + if (typeof CMSPluginTool !== 'undefined') { + builtInTools.cmsPlugin = { class: CMSPluginTool }; + } if (typeof SISStickyNavTool !== 'undefined') { builtInTools.stickyNav = { class: SISStickyNavTool }; } diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/cards.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/cards.js new file mode 100644 index 00000000..a4aeb2fc --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/cards.js @@ -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: '' + }; + } + + 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 = ' 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 = ''; + 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 = ''; + 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 = ''; + 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 = ''; + 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 = ' 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 = ` + + Card #${index + 1}: ${this.escapeHtml(item.title || 'Untitled Card')} + +
+ + ${index > 0 ? `` : ''} + ${index < this.data.items.length - 1 ? `` : ''} + ${this.data.items.length > 1 ? `` : ''} +
+ `; + + 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 = ''; + 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 = ` 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 = ''; + 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 = ''; + 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 = ''; + 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 = ''; + 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 { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[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 }; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/cms_plugin.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/cms_plugin.js index b8866577..bcc6b252 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/cms_plugin.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/cms_plugin.js @@ -1,6 +1,7 @@ /** * CMS Plugin Shortcode Tool for Editor.js (SIS Vietnam) * 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 { static get toolbox() { @@ -16,7 +17,11 @@ class CMSPluginTool { const initialVal = (data && (data.id || data.shortcode)) ? (data.id || data.shortcode) : '[plugin:price-table]'; this.data = { 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; } @@ -24,7 +29,7 @@ class CMSPluginTool { render() { this.wrapper = document.createElement('div'); 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.padding = '15px'; this.wrapper.style.marginBottom = '12px'; @@ -33,11 +38,11 @@ class CMSPluginTool { header.style.display = 'flex'; header.style.justifyContent = 'space-between'; header.style.alignItems = 'center'; - header.style.marginBottom = '10px'; + header.style.marginBottom = '12px'; const title = document.createElement('label'); title.className = 'font-weight-bold text-primary m-0'; - title.innerHTML = '🧩 CMS Plugin Shortcode Injection'; + title.innerHTML = ' CMS Plugin Shortcode & Custom Attributes'; title.style.fontSize = '14px'; header.appendChild(title); @@ -48,12 +53,11 @@ class CMSPluginTool { selectContainer.style.marginBottom = '10px'; 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:'; const select = document.createElement('select'); - select.className = 'form-control form-control-sm'; - select.style.marginBottom = '8px'; + select.className = 'form-control form-control-sm mb-2'; select.innerHTML = ` @@ -62,6 +66,8 @@ class CMSPluginTool { + + `; @@ -69,42 +75,114 @@ class CMSPluginTool { selectContainer.appendChild(select); this.wrapper.appendChild(selectContainer); - // Input Field + // Shortcode Input Field 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:'; - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'form-control font-weight-bold'; - input.placeholder = 'e.g. [plugin:price-table id="table_1"]'; - input.value = this.data.shortcode || this.data.id || ''; - if (this.readOnly) input.disabled = true; + this.shortcodeInput = document.createElement('input'); + this.shortcodeInput.type = 'text'; + this.shortcodeInput.className = 'form-control form-control-sm font-weight-bold mb-3'; + this.shortcodeInput.placeholder = 'e.g. [plugin:price-table id="table_1"]'; + this.shortcodeInput.value = this.data.shortcode || this.data.id || ''; + if (this.readOnly) this.shortcodeInput.disabled = true; select.addEventListener('change', (e) => { const val = e.target.value; if (val && val !== 'custom') { - input.value = val; + this.shortcodeInput.value = val; this.data.shortcode = val; this.data.id = val; } }); - input.addEventListener('input', (e) => { + this.shortcodeInput.addEventListener('input', (e) => { this.data.shortcode = e.target.value.trim(); this.data.id = e.target.value.trim(); }); this.wrapper.appendChild(inputLabel); - this.wrapper.appendChild(input); + 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 = ' 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 = ''; + 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 = ''; + 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 = ''; + 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 = ''; + 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; } save() { return { - id: this.data.id || this.data.shortcode || '', - shortcode: this.data.shortcode || this.data.id || '' + id: this.shortcodeInput ? this.shortcodeInput.value.trim() : (this.data.id || this.data.shortcode || ''), + 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'] = { class: CMSPluginTool }; + +window.CMSPluginTool = CMSPluginTool; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/flex.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/flex.js index d83b40c5..6d72b754 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/flex.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/flex.js @@ -22,6 +22,7 @@ class FlexTool { gap: data.gap || '3', // standard Bootstrap gap level globalClass: data.globalClass || '', globalId: data.globalId || '', + globalStyle: data.globalStyle || data.style || '', globalAttributes: data.globalAttributes || data.attributes || '' }; this.activeInstances = {}; @@ -36,9 +37,11 @@ class FlexTool { this.data[`accContent${i}`] = data[`accContent${i}`] || ''; this.data[`class${i}`] = data[`class${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[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto this.data[`caption${i}`] = data[`caption${i}`] || ''; + this.data[`stylesub_${i}`] = data[`stylesub_${i}`] || ''; } this.wrapper = undefined; } @@ -227,7 +230,22 @@ class FlexTool { idDiv.appendChild(idInput); 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 = ''; + 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'); attrDiv.className = 'col-md-3 mb-2'; attrDiv.innerHTML = ''; @@ -1381,6 +1399,7 @@ class FlexTool { gap: this.data.gap, globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || ''), 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 || '') }; for (let i = 1; i <= this.data.cols; i++) { diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/googleForm.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/googleForm.js new file mode 100644 index 00000000..50514e62 --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/googleForm.js @@ -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: '' + }; + } + + 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 = ' 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 = ''; + const typeSelect = document.createElement('select'); + typeSelect.className = 'form-control form-control-sm'; + typeSelect.innerHTML = ` + + + + `; + typeCol.appendChild(typeSelect); + + const themeCol = document.createElement('div'); + themeCol.className = 'col-md-6 mb-2'; + themeCol.innerHTML = ''; + const themeSelect = document.createElement('select'); + themeSelect.className = 'form-control form-control-sm'; + themeSelect.innerHTML = ` + + + + + `; + 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 = ''; + 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 = ''; + 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 = ''; + 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 = ''; + 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 = ''; + 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 = ''; + 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 = ''; + 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 = ' 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 = ' 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 = ` +
+ +
+ `; + } else { + previewBox.innerHTML = ` +
+ +

Vui lòng nhập Google Form Embed URL (src) để xem bản trước.

+
+ `; + } + } else if (fType === 'tri_an') { + customFieldsContainer.style.display = ''; + iframeFieldsContainer.style.display = 'none'; + previewBox.innerHTML = ` +
+
+

${titleVal}

+ ${subVal ? `

${subVal}

` : ''} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + Tải tệp +
+
+
+ +
+
+
+
+ `; + } else if (fType === 'contact') { + customFieldsContainer.style.display = ''; + iframeFieldsContainer.style.display = 'none'; + previewBox.innerHTML = ` +
+
+

${titleVal}

+ ${subVal ? `

${subVal}

` : ''} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+
+
+ `; + } + }; + + // 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; +})(); diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/hero-banner.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/hero-banner.js index 6ffae4b9..c70f19ee 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/hero-banner.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/hero-banner.js @@ -1,6 +1,7 @@ /** * SISHeroBannerTool — Hero Banner Plugin for Editor.js * 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 { static get toolbox() { @@ -10,22 +11,69 @@ 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 }) { this.api = api; this.readOnly = readOnly; this.data = { - title: (data && data.title) ? data.title : '', - subtitle: (data && data.subtitle) ? data.subtitle : '', - bgImage: (data && data.bgImage) ? data.bgImage : '', - btnText: (data && data.btnText) ? data.btnText : '', - btnLink: (data && data.btnLink) ? data.btnLink : '', - height: (data && data.height) ? data.height : '350px', - textAlign: (data && data.textAlign) ? data.textAlign : 'center', - overlayOpacity: (data && data.overlayOpacity !== undefined) ? data.overlayOpacity : '0.4', - stretched: (data && data.stretched !== undefined) ? !!data.stretched : true + title: (data && data.title) ? data.title : '', + subtitle: (data && data.subtitle) ? data.subtitle : '', + bgImage: (data && data.bgImage) ? data.bgImage : '', + btnText: (data && data.btnText) ? data.btnText : '', + btnLink: (data && data.btnLink) ? data.btnLink : '', + height: (data && data.height) ? data.height : '350px', + textAlign: (data && data.textAlign) ? data.textAlign : 'center', + overlayOpacity: (data && data.overlayOpacity !== undefined) ? data.overlayOpacity : '0.5', + 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() { const container = document.createElement('div'); container.style.border = '1px solid #e3e6f0'; @@ -39,7 +87,7 @@ class SISHeroBannerTool { headerLabel.innerHTML = ' Hero Banner Block Settings'; container.appendChild(headerLabel); - // Stretch toggle + // ── Stretch toggle ─────────────────────────────────────────────────────── const stretchWrapper = document.createElement('div'); stretchWrapper.className = 'custom-control custom-switch mb-3'; const stretchCheck = document.createElement('input'); @@ -47,113 +95,355 @@ class SISHeroBannerTool { stretchCheck.className = 'custom-control-input'; stretchCheck.id = 'hero_stretch_' + Math.random().toString(36).substring(7); stretchCheck.checked = !!this.data.stretched; - const stretchLabel = document.createElement('label'); stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary'; stretchLabel.htmlFor = stretchCheck.id; stretchLabel.innerHTML = ' 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(stretchLabel); container.appendChild(stretchWrapper); - // 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); + // ── 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.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); - - // Row for Height & Button Link + this.bgImageInput = this._createMediaPickerInput('Background Image URL', 'https://example.com/hero-banner.jpg', this.data.bgImage); + + // ── Height + Text Colour + Button row ──────────────────────────────────── const configRow = document.createElement('div'); configRow.className = 'form-row'; - const heightCol = document.createElement('div'); - heightCol.className = 'col-md-4 mb-2'; - this.heightInput = this._createInput('Banner Height (e.g. 350px, 500px, 60vh)', 'e.g. 350px', this.data.height); + const heightCol = document.createElement('div'); heightCol.className = 'col-md-3 mb-2'; + this.heightInput = this._createInput('Banner Height', 'e.g. 350px', this.data.height); heightCol.appendChild(this.heightInput); - const btnCol1 = document.createElement('div'); - btnCol1.className = 'col-md-4 mb-2'; + const textColorCol = document.createElement('div'); textColorCol.className = 'col-md-3 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); btnCol1.appendChild(this.btnTextInput); - const btnCol2 = document.createElement('div'); - btnCol2.className = 'col-md-4 mb-2'; + const btnCol2 = document.createElement('div'); btnCol2.className = 'col-md-3 mb-2'; this.btnLinkInput = this._createInput('Button Link URL (Optional)', 'e.g. /dat-lich', this.data.btnLink); btnCol2.appendChild(this.btnLinkInput); configRow.appendChild(heightCol); + configRow.appendChild(textColorCol); configRow.appendChild(btnCol1); 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 = ' 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 = ' Advanced Attributes (CSS Class, Element ID, Style, Custom Attributes)'; + + 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'); 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.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'); - overlay.style.position = 'absolute'; - overlay.style.top = '0'; - overlay.style.left = '0'; - overlay.style.right = '0'; - overlay.style.bottom = '0'; - overlay.style.background = '#000'; - overlay.style.zIndex = '1'; + overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:1;'; previewBox.appendChild(overlay); const contentBox = document.createElement('div'); - contentBox.style.position = 'relative'; - contentBox.style.zIndex = '2'; + contentBox.style.cssText = 'position:relative;z-index:2;'; previewBox.appendChild(contentBox); + // ── updatePreview() ────────────────────────────────────────────────────── const updatePreview = () => { - const bg = this.bgImageInput.querySelector('input').value.trim(); - const title = this.titleInput.querySelector('input').value.trim() || 'Hero Banner Title'; - const sub = this.subtitleInput.querySelector('input').value.trim(); - const btnT = this.btnTextInput.querySelector('input').value.trim(); - const bannerH = this.heightInput.querySelector('input').value.trim() || '350px'; + const bg = this.bgImageInput.querySelector('input').value.trim(); + const title = this.titleInput.querySelector('input').value.trim() || 'Hero Banner Title'; + const sub = this.subtitleInput.querySelector('input').value.trim(); + const btnT = this.btnTextInput.querySelector('input').value.trim(); + const h = this.heightInput.querySelector('input').value.trim() || '350px'; - previewBox.style.minHeight = bannerH; - previewBox.style.backgroundImage = bg ? 'url("' + bg + '")' : 'linear-gradient(135deg, #002554, #881C1C)'; - overlay.style.opacity = this.data.overlayOpacity || '0.4'; + previewBox.style.minHeight = h; + previewBox.style.backgroundImage = bg ? `url("${bg}")` : 'linear-gradient(135deg, #002554, #881C1C)'; - let html = '

' + title + '

'; - if (sub) html += '

' + sub + '

'; - if (btnT) html += '' + btnT + ''; + // Build gradient + const preset = SISHeroBannerTool.GRADIENT_PRESETS.find(p => p.id === this.data.gradientType) + || 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 = `

${title}

`; + if (sub) html += `

${sub}

`; + if (btnT) html += `${btnT}`; contentBox.innerHTML = html; }; + // Wire all text inputs to preview [this.titleInput, this.subtitleInput, this.bgImageInput].forEach(wrapper => { const input = wrapper.querySelector('input'); input.addEventListener('input', updatePreview); if (this.readOnly) input.disabled = true; container.appendChild(wrapper); }); - [this.heightInput, this.btnTextInput, this.btnLinkInput].forEach(wrapper => { const input = wrapper.querySelector('input'); input.addEventListener('input', updatePreview); if (this.readOnly) input.disabled = true; }); + if (this.textColorInput) { + this.textColorInput.querySelectorAll('input').forEach(inp => { + inp.addEventListener('input', updatePreview); + }); + } container.appendChild(configRow); + container.appendChild(gradSection); + container.appendChild(advSection); container.appendChild(previewBox); updatePreview(); return container; } + // ─── Helpers ───────────────────────────────────────────────────────────────── _createInput(labelText, placeholder, value) { const wrapper = document.createElement('div'); wrapper.className = 'form-group mb-2'; @@ -170,10 +460,50 @@ class SISHeroBannerTool { return wrapper; } - _createImageInput(labelText, placeholder, value) { + _createColorPickerInput(labelText, value) { const wrapper = document.createElement('div'); 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'); lbl.className = 'small font-weight-bold text-secondary mb-1 d-block'; lbl.innerText = labelText; @@ -187,56 +517,78 @@ class SISHeroBannerTool { inp.className = 'form-control'; inp.placeholder = placeholder; inp.value = value || ''; - if (this.readOnly) inp.disabled = true; inputGroup.appendChild(inp); - if (!this.readOnly) { - const appendDiv = document.createElement('div'); - appendDiv.className = 'input-group-append'; - - const mediaBtn = document.createElement('button'); - mediaBtn.type = 'button'; - mediaBtn.className = 'btn btn-outline-info'; - mediaBtn.innerHTML = ' Media Library'; - mediaBtn.addEventListener('click', () => { - if (window.SISMediaPicker) { - SISMediaPicker.open((selectedUrl, mediaObj, config) => { - inp.value = selectedUrl; - if (config) { - this._bgImageConfig = config; - } - // Dispatch input event to trigger preview update - inp.dispatchEvent(new Event('input', { bubbles: true })); - }); - } else { - alert('Media modal function openSISMediaModal is not available.'); + const appendDiv = document.createElement('div'); + 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 = ''; + 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; } - }); - appendDiv.appendChild(mediaBtn); - inputGroup.appendChild(appendDiv); - } + + if (selectedUrl) { + inp.value = selectedUrl; + this._bgImageConfig = config || mediaObj || null; + this.data.bgImage = selectedUrl; + inp.dispatchEvent(new Event('input', { bubbles: true })); + } + }; + + 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(btn); + inputGroup.appendChild(appendDiv); wrapper.appendChild(inputGroup); return wrapper; } + // ─── save() ─────────────────────────────────────────────────────────────────── save(blockContent) { const stretchCheck = blockContent.querySelector('.custom-control-input'); return { - title: this.titleInput ? this.titleInput.querySelector('input').value : this.data.title, - subtitle: this.subtitleInput ? this.subtitleInput.querySelector('input').value : this.data.subtitle, - bgImage: this.bgImageInput ? this.bgImageInput.querySelector('input').value : this.data.bgImage, - bgImageStyle: (this._bgImageConfig && this._bgImageConfig.style) || this.data.bgImageStyle || '', - bgImageClass: (this._bgImageConfig && this._bgImageConfig.cssClass) || this.data.bgImageClass || '', - bgImageAlt: (this._bgImageConfig && this._bgImageConfig.alt) || this.data.bgImageAlt || '', - bgImageAttrs: (this._bgImageConfig && this._bgImageConfig.customAttributes) || this.data.bgImageAttrs || '', - bgImageAspectRatio: (this._bgImageConfig && this._bgImageConfig.aspectRatio) || this.data.bgImageAspectRatio || '', - btnText: this.btnTextInput ? this.btnTextInput.querySelector('input').value : this.data.btnText, - btnLink: this.btnLinkInput ? this.btnLinkInput.querySelector('input').value : this.data.btnLink, - height: this.heightInput ? this.heightInput.querySelector('input').value : this.data.height, - textAlign: this.data.textAlign || 'center', - overlayOpacity: this.data.overlayOpacity || '0.4', - stretched: stretchCheck ? stretchCheck.checked : !!this.data.stretched + title: this.titleInput ? this.titleInput.querySelector('input').value : this.data.title, + subtitle: this.subtitleInput ? this.subtitleInput.querySelector('input').value : this.data.subtitle, + bgImage: this.bgImageInput ? this.bgImageInput.querySelector('input').value : this.data.bgImage, + bgImageStyle: (this._bgImageConfig && this._bgImageConfig.style) || this.data.bgImageStyle || '', + bgImageClass: (this._bgImageConfig && this._bgImageConfig.cssClass) || this.data.bgImageClass || '', + bgImageAlt: (this._bgImageConfig && this._bgImageConfig.alt) || this.data.bgImageAlt || '', + bgImageAttrs: (this._bgImageConfig && this._bgImageConfig.customAttributes) || this.data.bgImageAttrs || '', + bgImageAspectRatio: (this._bgImageConfig && this._bgImageConfig.aspectRatio) || this.data.bgImageAspectRatio || '', + btnText: this.btnTextInput ? this.btnTextInput.querySelector('input').value : this.data.btnText, + btnLink: this.btnLinkInput ? this.btnLinkInput.querySelector('input').value : this.data.btnLink, + height: this.heightInput ? this.heightInput.querySelector('input').value : this.data.height, + textAlign: this.data.textAlign || 'center', + overlayOpacity: this.data.overlayOpacity || '0.5', + 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', }; } } diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/posts.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/posts.js index 233abbf1..a5a40679 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/posts.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/posts.js @@ -40,6 +40,8 @@ class SISPostsTool { itemsPerPage: parseInt(data && data.itemsPerPage) || parseInt(data && data.limit) || 12, enablePagination: data && data.enablePagination !== undefined ? !!data.enablePagination : 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 : '', globalClass: data && data.globalClass ? data.globalClass : 'sis-posts-block', globalStyle: data && data.globalStyle ? data.globalStyle : '', @@ -115,7 +117,7 @@ class SISPostsTool { // 3. Limit (Number of posts) const limitDiv = document.createElement('div'); limitDiv.className = 'col-md-2 mb-2'; - limitDiv.innerHTML = ''; + limitDiv.innerHTML = ''; const limitInput = document.createElement('input'); limitInput.type = 'number'; limitInput.className = 'form-control form-control-sm'; @@ -161,35 +163,32 @@ class SISPostsTool { const formRow2 = document.createElement('div'); 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'); catDiv.className = 'col-md-3 mb-2'; - catDiv.innerHTML = ''; + catDiv.innerHTML = ''; 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.style.height = '85px'; + catSelect.setAttribute('data-placeholder', '-- All Categories --'); const populateCatSelect = () => { + // Preserve current Select2 selection before repopulating + const currentVals = (this.data.categoryFilter || '').split(',').map(s => s.trim()).filter(Boolean); 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 selectedVals = (this.data.categoryFilter || '').split(',').map(s => s.trim()).filter(Boolean); - list.forEach(c => { const val = (typeof c === 'string') ? c : (c.name || c.title || ''); if (!val) return; const opt = document.createElement('option'); opt.value = val; opt.textContent = val; - if (selectedVals.includes(val)) opt.selected = true; + if (currentVals.includes(val)) opt.selected = true; catSelect.appendChild(opt); }); + if (typeof $ !== 'undefined' && $(catSelect).data('select2')) { + $(catSelect).trigger('change.select2'); + } }; populateCatSelect(); @@ -200,44 +199,40 @@ class SISPostsTool { if (this.readOnly) catSelect.disabled = true; catSelect.addEventListener('change', () => { const selected = Array.from(catSelect.selectedOptions).map(o => o.value); - if (selected.includes('ALL') || selected.includes('')) { - this.data.categoryFilter = ''; - } else { - this.data.categoryFilter = selected.filter(v => v !== 'ALL').join(','); - } + this.data.categoryFilter = selected.filter(v => v !== 'ALL' && v !== '').join(','); + updateViewMoreVisibility(); }); catDiv.appendChild(catSelect); 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'); tagDiv.className = 'col-md-3 mb-2'; - tagDiv.innerHTML = ''; + tagDiv.innerHTML = ''; 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.style.height = '85px'; + tagSelect.setAttribute('data-placeholder', '-- All Tags --'); const populateTagSelect = () => { + const currentVals = (this.data.tagFilter || '').split(',').map(s => s.trim()).filter(Boolean); 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 selectedVals = (this.data.tagFilter || '').split(',').map(s => s.trim()).filter(Boolean); - list.forEach(t => { const val = (typeof t === 'string') ? t : (t.name || t.title || ''); if (!val) return; const opt = document.createElement('option'); opt.value = val; opt.textContent = val; - if (selectedVals.includes(val)) opt.selected = true; + if (currentVals.includes(val)) opt.selected = true; tagSelect.appendChild(opt); }); + if (typeof $ !== 'undefined' && $(tagSelect).data('select2')) { + $(tagSelect).trigger('change.select2'); + } }; populateTagSelect(); @@ -248,15 +243,49 @@ class SISPostsTool { if (this.readOnly) tagSelect.disabled = true; tagSelect.addEventListener('change', () => { const selected = Array.from(tagSelect.selectedOptions).map(o => o.value); - if (selected.includes('ALL') || selected.includes('')) { - this.data.tagFilter = ''; - } else { - this.data.tagFilter = selected.filter(v => v !== 'ALL').join(','); - } + this.data.tagFilter = selected.filter(v => v !== 'ALL' && v !== '').join(','); + updateViewMoreVisibility(); }); tagDiv.appendChild(tagSelect); 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 = ' 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 const locDiv = document.createElement('div'); locDiv.className = 'col-md-3 mb-2'; @@ -364,7 +393,8 @@ class SISPostsTool { { id: 'showDt', key: 'showDate', label: 'Published / Event Date' }, { id: 'showLoc', key: 'showLocation', label: 'Location Tag' }, { 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 => { @@ -390,9 +420,76 @@ class SISPostsTool { 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 = ' 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; } + /** + * Initialize Select2 on a + + + + + + + +
+ + + +
+ + +
+ +
+
+
+
+
+ + + + + + + +
+
+ + + + + + + + + + +
+ +
+
+ + + + + + + + + + + + +
+ + + + + +
    +
    + + +
    + + + +
    + + + + + +
    +
    + + + + +
    + + + + + +
    + +
    + +
    + Category + +
    + Title + +
    +
    +
    + + + +
    +
    +
    + + + + +
    + + +
    +
    +
    + +
    +
    +
    +
    + Category + 01/01/2026 +
    +
    + Post Title +
    +

    Excerpt...

    +
    +
    + Location +
    +
    +
    +
    +
    +
    +
    +
      +
      + + + +
      +
      +
      + + + +
      + + + +
      + + +
      +
      +
      +
      + + + + +
      +
      +

      Chia sẻ lời tri ân

      +

      + +
      +
      + + +
      +
      +
      + + +
      +
      + + +
      +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + +
      +
      +
      +
      +
      + + + +
      +
      +

      Form Liên hệ & Góp ý

      +

      + +
      +
      + + +
      +
      +
      + + +
      +
      + + +
      +
      +
      + + +
      +
      + +
      +
      +
      +
      +
      + +
      +
      + + + + + + +
      +
      +
      +
      +
      + + + + +
      +
      +
      +
      +
      + + + +
      + + + + +
      +
      + + + +
      +
      +
      + + + + +
      +
      +
      +
      + + + +
      + + Accordion Title + +
      +
      +
      + + + +
      + + + + + +
      +

      Form

      +

      +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
      + + + + +
      +
      +
      + +
      +

      Hero Banner Title

      +

      + Click Here +
      +
      +
      +
      + + + + +
      +
      + + + + +
      +
      +
      +
      +
      + + + +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
      + Timeline Image +
      +
      +
      +
      +
      +
      +
      +
      + + + + +
      +
      +
      + +
      + +
      + + +
      + + + +
      + + +
      + +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      + + + + +
      +
      +
      + +
      +
      +
      Title
      + +
      +
      +
      +
      +
      +
      +
      +
      + + + + +
      +
      +
      + +
      +
      +
      +
      + + +
      +
      +
      +

      Title

      +

      Description

      +
      +
      +
      +
      +
      +
      +
      +
      +
      + + + + + + + + + + + +
      +
      +
      + +
      +
      +
      +
      +
      +
      +
      +
      +
      + + + + +
      +
      +
      + +
      +
      +
      +
      +
      +
      +
      +
      +
      + + +
      +
      +
      + + + +
      +
      +
      +
      +
      + + + + + + + diff --git a/sisvietnamvn_main/src/main/resources/templates/fragments/layout.html b/sisvietnamvn_main/src/main/resources/templates/fragments/layout.html index 25087477..4ca922e6 100644 --- a/sisvietnamvn_main/src/main/resources/templates/fragments/layout.html +++ b/sisvietnamvn_main/src/main/resources/templates/fragments/layout.html @@ -22,6 +22,10 @@ + + + + diff --git a/sisvietnamvn_main/src/main/resources/templates/fragments/manage-layout.html b/sisvietnamvn_main/src/main/resources/templates/fragments/manage-layout.html index 7d6161c7..0a90ebd5 100644 --- a/sisvietnamvn_main/src/main/resources/templates/fragments/manage-layout.html +++ b/sisvietnamvn_main/src/main/resources/templates/fragments/manage-layout.html @@ -23,6 +23,9 @@ + + + @@ -407,6 +410,13 @@ + @@ -415,6 +425,9 @@ + + +
      diff --git a/sisvietnamvn_main/src/main/resources/templates/manage/login.html b/sisvietnamvn_main/src/main/resources/templates/manage/login.html index 1056b79c..fb302a0f 100644 --- a/sisvietnamvn_main/src/main/resources/templates/manage/login.html +++ b/sisvietnamvn_main/src/main/resources/templates/manage/login.html @@ -49,6 +49,9 @@
      Bạn đã đăng xuất thành công / You have been logged out.
      +
      + Phiên làm việc đã hết hạn. Vui lòng đăng nhập lại. / Session expired. Please log in again. +
      diff --git a/sisvietnamvn_main/src/main/resources/templates/manage/pages/form.html b/sisvietnamvn_main/src/main/resources/templates/manage/pages/form.html index 06d5c442..dce34436 100644 --- a/sisvietnamvn_main/src/main/resources/templates/manage/pages/form.html +++ b/sisvietnamvn_main/src/main/resources/templates/manage/pages/form.html @@ -226,6 +226,9 @@ Custom CSS (Page-Specific Styles)
      + @@ -460,12 +463,14 @@ - + + + @@ -701,6 +706,85 @@ 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'); if (btnGenCss) { 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 function enableTabIndentation(textarea) { if (!textarea) return; diff --git a/sisvietnamvn_main/src/main/resources/templates/manage/posts/form.html b/sisvietnamvn_main/src/main/resources/templates/manage/posts/form.html index a5483f7d..e5538de4 100644 --- a/sisvietnamvn_main/src/main/resources/templates/manage/posts/form.html +++ b/sisvietnamvn_main/src/main/resources/templates/manage/posts/form.html @@ -214,6 +214,9 @@ Custom CSS (Post-Specific Styles)
      + @@ -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('postCustomJs')); }); diff --git a/sisvietnamvn_main/src/main/resources/templates/page.html b/sisvietnamvn_main/src/main/resources/templates/page.html index d72cecbe..7a0bd0fa 100644 --- a/sisvietnamvn_main/src/main/resources/templates/page.html +++ b/sisvietnamvn_main/src/main/resources/templates/page.html @@ -3,7 +3,7 @@ lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" - layout:decorate="~{themes/__${activeTheme}__/layout}" + layout:decorate="~{themes/__${activeTheme}__/layout(bodyClass=${bodyClass})}" > Bệnh Viện S.I.S Cần Thơ @@ -12,6 +12,8 @@ + + + + + + + +
      - -
      + +
      @@ -1146,47 +1292,47 @@
      +
        - - @@ -1337,37 +1565,170 @@
        + + +
        + + + +
        + + +
        +
        +
        +
        + + + + +
        +
        +

        Chia sẻ lời tri ân

        +

        + + +
        + + +
        +
        +
        + + +
        +
        + + +
        +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + +
        + +
        +
        +
        + + + +
        +
        +

        Form Liên hệ & Góp ý

        +

        + +
        +
        + + +
        +
        +
        + + +
        +
        + + +
        +
        +
        + + +
        +
        + +
        +
        +
        +
        +
        + +
        +
        + - - - - - - - - - - - + +
        +
        +
        - - - - - - - - - - - + +
        +
        +
        @@ -1437,6 +1798,113 @@
        + + +
        + + + + + +
        +

        Form

        +

        +
        +
        +
        +
        + +
        +
        +
        +
        +
        +
        + + + + +
        +
        +
        + +
        +

        Hero Banner Title

        +

        + Click Here +
        +
        +
        +
        + +
        @@ -1444,8 +1912,13 @@ - -
        + +
        +
        +
        @@ -1524,6 +1997,38 @@
        + + + +
        +
        +
        + +
        +
        +
        Title
        + +
        +
        +
        +
        +
        +
        +
        +
        + diff --git a/sisvietnamvn_main/src/main/resources/templates/plugins/google-form/admin-settings.html b/sisvietnamvn_main/src/main/resources/templates/plugins/google-form/admin-settings.html new file mode 100644 index 00000000..fc535e39 --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/templates/plugins/google-form/admin-settings.html @@ -0,0 +1,353 @@ + + + Quản lý Google Form Settings + + + +
        +
        + +
        +

        Quản lý Google Form & Form Tương Tác

        +
        + +
        +
        + + + + + + +
        + + + +
        +
        +
        Danh sách các Form trong hệ thống
        + +
        +
        +
        + + + + + + + + + + + + + + + +
        STTID (Slug)Tên FormLoại FormGiao diện (Theme)Shortcode nhúngThao tác
        +
        +
        +
        +
        + + +
        +
        +
        Hướng dẫn sử dụng Google Form Shortcode
        +

        + Dùng shortcode [plugin:google-form id="tri-an"] 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.
        + 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. +

        +
        +
        +
        + + + + + +
        + + diff --git a/sisvietnamvn_main/src/main/resources/templates/themes/umass/layout.html b/sisvietnamvn_main/src/main/resources/templates/themes/umass/layout.html index 10727ede..0ee34adb 100644 --- a/sisvietnamvn_main/src/main/resources/templates/themes/umass/layout.html +++ b/sisvietnamvn_main/src/main/resources/templates/themes/umass/layout.html @@ -21,7 +21,7 @@
        diff --git a/sisvietnamvn_main/src/main/resources/templates/themes/umass/snippets/block_11_charity.html b/sisvietnamvn_main/src/main/resources/templates/themes/umass/snippets/block_11_charity.html index e1f6e5e2..232ab22a 100644 --- a/sisvietnamvn_main/src/main/resources/templates/themes/umass/snippets/block_11_charity.html +++ b/sisvietnamvn_main/src/main/resources/templates/themes/umass/snippets/block_11_charity.html @@ -207,16 +207,21 @@ } .charity-hero .photo-card { + /* Anchored at origin — position driven entirely by transform (GPU-composited) */ position: absolute; - left: var(--x); - top: var(--y); - width: var(--w); + left: 0; + top: 0; + /* Fixed base size — scale() handles apparent width without layout recalc */ + width: 400px; background: #fff; border: 4px solid var(--red); border-radius: 3px; overflow: hidden; 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; z-index: 5; will-change: transform, opacity; @@ -231,6 +236,19 @@ 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 { display: block; width: 100%; @@ -257,10 +275,9 @@ } .charity-hero .photo-card.is-active { - left: 50%; - top: 50%; - width: min(480px, 58vw); - transform: translate(-50%, -50%) rotate(0deg) scale(1.06) !important; + /* Centre in stage: shift to (stageW - cardW) / 2 horizontally, (stageH - cardH) / 2 vertically */ + /* Card is 400×300px base; --sw/--sh are stage pixel dims set by JS */ + transform: translate(calc((var(--sw, 700px) - 400px) / 2), calc((var(--sh, 430px) - 300px) / 2)) rotate(0deg) scale(1.18) !important; opacity: 1; z-index: 30; border-color: #fff; @@ -277,7 +294,8 @@ .charity-hero .photo-card.is-dim { opacity: 0.28; 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; } @@ -288,7 +306,8 @@ .charity-hero .photo-card.is-exit { 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 { @@ -512,11 +531,13 @@ @keyframes cardIntro { from { 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 { 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 */ @@ -622,10 +643,12 @@ overflow: hidden; } .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 { - 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 { padding: 8px; @@ -676,32 +699,32 @@
        @@ -751,6 +774,40 @@ const dots = Array.from(container.querySelectorAll('.dot')); 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 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 */ @@ -774,23 +831,24 @@ }); 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() { setActive(active + 1); } - function start() { stop(); timer = setInterval(next, duration); } - function stop() { if (timer) clearInterval(timer); } - // Hệ thống sẽ chạy tự động 100% - dots.forEach((dot, i) => { dot.addEventListener('click', () => { setActive(i); diff --git a/sisvietnamvn_main/test-editorjs.html b/sisvietnamvn_main/test-editorjs.html new file mode 100644 index 00000000..44f58d5c --- /dev/null +++ b/sisvietnamvn_main/test-editorjs.html @@ -0,0 +1,48 @@ + + + + + + + + +
        + + + diff --git a/sisvietnamvn_main/test-editorjs2.html b/sisvietnamvn_main/test-editorjs2.html new file mode 100644 index 00000000..c1adc5e6 --- /dev/null +++ b/sisvietnamvn_main/test-editorjs2.html @@ -0,0 +1,37 @@ + + + + + + +
        + + + diff --git a/sisvietnamvn_main/test-event.html b/sisvietnamvn_main/test-event.html new file mode 100644 index 00000000..e524a18e --- /dev/null +++ b/sisvietnamvn_main/test-event.html @@ -0,0 +1,24 @@ + + +
        + +
        + + + diff --git a/sisvietnamvn_main/uploads/2026/08/Vuot-hon-1.300-km-tim-hy-vong-nguoi-dan-ong-liet-nua-nguoi-hoi-phuc-an-tuong-tai-S.I.S-Can-Tho-1-1024x576.jpg b/sisvietnamvn_main/uploads/2026/08/Vuot-hon-1.300-km-tim-hy-vong-nguoi-dan-ong-liet-nua-nguoi-hoi-phuc-an-tuong-tai-S.I.S-Can-Tho-1-1024x576.jpg new file mode 100644 index 00000000..f52c8f2f Binary files /dev/null and b/sisvietnamvn_main/uploads/2026/08/Vuot-hon-1.300-km-tim-hy-vong-nguoi-dan-ong-liet-nua-nguoi-hoi-phuc-an-tuong-tai-S.I.S-Can-Tho-1-1024x576.jpg differ diff --git a/sisvietnamvn_main/uploads/2026/08/photo-01.jpg b/sisvietnamvn_main/uploads/2026/08/photo-01.jpg new file mode 100644 index 00000000..1112de65 Binary files /dev/null and b/sisvietnamvn_main/uploads/2026/08/photo-01.jpg differ diff --git a/sisvietnamvn_main/uploads/2026/08/photo-02.jpg b/sisvietnamvn_main/uploads/2026/08/photo-02.jpg new file mode 100644 index 00000000..a7e225b3 Binary files /dev/null and b/sisvietnamvn_main/uploads/2026/08/photo-02.jpg differ diff --git a/sisvietnamvn_main/uploads/2026/08/photo-03.jpg b/sisvietnamvn_main/uploads/2026/08/photo-03.jpg new file mode 100644 index 00000000..af204c6c Binary files /dev/null and b/sisvietnamvn_main/uploads/2026/08/photo-03.jpg differ diff --git a/sisvietnamvn_main/uploads/2026/08/photo-04.jpg b/sisvietnamvn_main/uploads/2026/08/photo-04.jpg new file mode 100644 index 00000000..798e3fae Binary files /dev/null and b/sisvietnamvn_main/uploads/2026/08/photo-04.jpg differ diff --git a/sisvietnamvn_main/uploads/2026/08/photo-05.jpg b/sisvietnamvn_main/uploads/2026/08/photo-05.jpg new file mode 100644 index 00000000..c5b4e062 Binary files /dev/null and b/sisvietnamvn_main/uploads/2026/08/photo-05.jpg differ diff --git a/sisvietnamvn_main/uploads/2026/08/photo-06.jpg b/sisvietnamvn_main/uploads/2026/08/photo-06.jpg new file mode 100644 index 00000000..a88f4deb Binary files /dev/null and b/sisvietnamvn_main/uploads/2026/08/photo-06.jpg differ diff --git a/sisvietnamvn_main/uploads/2026/08/photo-07.jpg b/sisvietnamvn_main/uploads/2026/08/photo-07.jpg new file mode 100644 index 00000000..8ac69d45 Binary files /dev/null and b/sisvietnamvn_main/uploads/2026/08/photo-07.jpg differ diff --git a/sisvietnamvn_main/uploads/2026/08/photo-08.jpg b/sisvietnamvn_main/uploads/2026/08/photo-08.jpg new file mode 100644 index 00000000..4d76c2c1 Binary files /dev/null and b/sisvietnamvn_main/uploads/2026/08/photo-08.jpg differ diff --git a/sisvietnamvn_main/uploads/2026/08/photo-09.jpg b/sisvietnamvn_main/uploads/2026/08/photo-09.jpg new file mode 100644 index 00000000..d8a9ca34 Binary files /dev/null and b/sisvietnamvn_main/uploads/2026/08/photo-09.jpg differ diff --git a/sisvietnamvn_main/uploads/2026/08/user%201.webp b/sisvietnamvn_main/uploads/2026/08/user%201.webp new file mode 100644 index 00000000..bc405b8d Binary files /dev/null and b/sisvietnamvn_main/uploads/2026/08/user%201.webp differ