feat: implement Google Form plugin and enhance TinyMCE editor with advanced configuration attributes
@@ -3,278 +3,164 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Trang Tin Tức - Bài Viết Nổi Bật</title>
|
||||
<title>Chia sẻ lời tri ân</title>
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
/* ===================================================
|
||||
1. RESET & BASIC STYLES
|
||||
=================================================== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8fafc;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
2. GRID LAYOUT CHÍNH (12 CỘT)
|
||||
=================================================== */
|
||||
.news-section {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Component thẻ Badge (TIN TỨC / SỰ KIỆN) */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
3. CỘT TRÁI - BÀI VIẾT NỔI BẬT (FEATURED POST)
|
||||
=================================================== */
|
||||
.featured-post {
|
||||
grid-column: span 6; /* Chiếm 6/12 cột */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.featured-img-box {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-img-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.featured-post:hover .featured-img-box img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#1e3a8a 0%,
|
||||
#3b82f6 50%,
|
||||
#eff6ff 100%
|
||||
);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.featured-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0284c7; /* Màu xanh chủ đạo */
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.featured-title:hover {
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.featured-excerpt {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
4. CỘT PHẢI - LƯỚI 4 BÀI NHỎ (GRID 2x2)
|
||||
=================================================== */
|
||||
.side-news-grid {
|
||||
grid-column: span 6; /* Chiếm 6/12 cột còn lại */
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr); /* Tách thành 2 cột nhỏ */
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.small-post {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.small-img-box {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.small-img-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.small-post:hover .small-img-box img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.small-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3; /* Giới hạn tối đa 3 dòng */
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.small-post:hover .small-title {
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
5. RESPONSIVE (Cho Tablet & Mobile)
|
||||
=================================================== */
|
||||
@media (max-width: 992px) {
|
||||
.featured-post,
|
||||
.side-news-grid {
|
||||
grid-column: span 12; /* Chuyển thành 1 cột dọc lớn */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.side-news-grid {
|
||||
grid-template-columns: 1fr; /* Lưới bài nhỏ chuyển thành 1 cột trên Mobile */
|
||||
}
|
||||
.featured-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
justify-content: center;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="news-section">
|
||||
<!-- CỘT TRÁI: Bài viết nổi bật lớn -->
|
||||
<article class="featured-post">
|
||||
<div class="featured-img-box">
|
||||
<img
|
||||
src="https://picsum.photos/800/450?random=1"
|
||||
alt="Nhà giáo nhân dân Nguyễn Đình Hối"
|
||||
<body class="p-4 sm:p-6 md:p-8">
|
||||
<!-- Khung chứa Form: Responsive padding, bo góc và độ rộng -->
|
||||
<div
|
||||
class="bg-white rounded-2xl shadow-2xl w-full max-w-xl p-6 sm:p-8 md:p-10 relative my-auto"
|
||||
>
|
||||
<!-- Tiêu đề -->
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-blue-900 mb-6">
|
||||
Chia sẻ lời tri ân
|
||||
</h2>
|
||||
|
||||
<!-- Form chính -->
|
||||
<form
|
||||
onsubmit="
|
||||
event.preventDefault();
|
||||
alert('Gửi thông tin thành công!');
|
||||
"
|
||||
class="space-y-4 sm:space-y-5"
|
||||
>
|
||||
<!-- Họ tên -->
|
||||
<div>
|
||||
<label class="block text-xs sm:text-sm font-medium text-gray-700 mb-1"
|
||||
>Họ tên <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Nhập họ tên"
|
||||
class="w-full px-3.5 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="badge">Tin tức</span>
|
||||
<span class="post-date">06/07/2026</span>
|
||||
|
||||
<!-- Bạn là & Chức vụ (Responsive Grid: 1 cột trên mobile, 2 cột từ màn hình sm trở lên) -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs sm:text-sm font-medium text-gray-700 mb-1"
|
||||
>Bạn là <span class="text-red-500">*</span></label
|
||||
>
|
||||
<select
|
||||
required
|
||||
class="w-full px-3.5 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm bg-white text-gray-500"
|
||||
>
|
||||
<option value="" disabled selected>Chọn vai trò</option>
|
||||
<option value="bac-si">Bác sĩ / Nhân viên y tế</option>
|
||||
<option value="benh-nhan">Bệnh nhân / Người nhà</option>
|
||||
<option value="khac">Khác</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs sm:text-sm font-medium text-gray-700 mb-1"
|
||||
>Chức vụ/ Nơi làm việc của bạn
|
||||
<span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Nhập chức vụ/ nơi làm việc"
|
||||
class="w-full px-3.5 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#" class="featured-title">Thư cảm tạ</a>
|
||||
<p class="featured-excerpt">
|
||||
Đại học Y Dược Thành phố Hồ Chí Minh, Bệnh viện Đại học Y Dược Thành
|
||||
phố Hồ Chí Minh và gia quyến vô cùng xúc động và trân trọng gửi lời
|
||||
cảm tạ sâu sắc.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<!-- CỘT PHẢI: Lưới 4 bài viết nhỏ (2x2) -->
|
||||
<div class="side-news-grid">
|
||||
<!-- Bài nhỏ 1 -->
|
||||
<a href="#" class="small-post">
|
||||
<div class="small-img-box">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=2"
|
||||
alt="Tuần lễ thế giới nuôi con bằng sữa mẹ"
|
||||
/>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="badge">Sự kiện</span>
|
||||
</div>
|
||||
<h3 class="small-title">
|
||||
Tuần lễ thế giới nuôi con bằng sữa mẹ 2026
|
||||
</h3>
|
||||
<span class="post-date">04/08/2026</span>
|
||||
</a>
|
||||
<!-- Số điện thoại -->
|
||||
<div>
|
||||
<label class="block text-xs sm:text-sm font-medium text-gray-700 mb-1"
|
||||
>Số điện thoại <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="Nhập số điện thoại"
|
||||
class="w-full px-3.5 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bài nhỏ 2 -->
|
||||
<a href="#" class="small-post">
|
||||
<div class="small-img-box">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=3"
|
||||
alt="Đau lưng ở nhân viên văn phòng"
|
||||
/>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="badge">Tin tức</span>
|
||||
</div>
|
||||
<h3 class="small-title">
|
||||
Đau lưng ở nhân viên văn phòng: Đừng đợi đến khi cột sống “lên
|
||||
tiếng”
|
||||
</h3>
|
||||
<span class="post-date">01/08/2026</span>
|
||||
</a>
|
||||
<!-- Nội dung lời nhắn -->
|
||||
<div>
|
||||
<label class="block text-xs sm:text-sm font-medium text-gray-700 mb-1"
|
||||
>Nội dung lời nhắn <span class="text-red-500">*</span></label
|
||||
>
|
||||
<textarea
|
||||
rows="3"
|
||||
required
|
||||
placeholder="Nhập nội dung"
|
||||
class="w-full px-3.5 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm placeholder-gray-400 resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Bài nhỏ 3 -->
|
||||
<a href="#" class="small-post">
|
||||
<div class="small-img-box">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=4"
|
||||
alt="Bệnh lý miễn dịch thần kinh"
|
||||
/>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="badge">Tin tức</span>
|
||||
</div>
|
||||
<h3 class="small-title">
|
||||
‘Bệnh lý miễn dịch thần kinh - Phát hiện sớm để điều trị tốt’
|
||||
</h3>
|
||||
<span class="post-date">31/07/2026</span>
|
||||
</a>
|
||||
<!-- Tải lên hình ảnh -->
|
||||
<div>
|
||||
<label class="block text-xs sm:text-sm font-medium text-gray-700 mb-1"
|
||||
>Hình ảnh <span class="text-red-500">*</span></label
|
||||
>
|
||||
<label
|
||||
class="flex flex-col items-center justify-center w-28 h-28 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-blue-500 bg-gray-50 transition"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center pt-5 pb-6 text-gray-400"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 mb-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-xs">Tải tệp</span>
|
||||
</div>
|
||||
<input type="file" class="hidden" accept="image/*" required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Bài nhỏ 4 -->
|
||||
<a href="#" class="small-post">
|
||||
<div class="small-img-box">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=5"
|
||||
alt="Phẫu thuật nội soi"
|
||||
/>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="badge">Tin tức</span>
|
||||
</div>
|
||||
<h3 class="small-title">
|
||||
Phẫu thuật nội soi cắt phì đại liên thất và thay van động mạch chủ
|
||||
</h3>
|
||||
<span class="post-date">30/07/2026</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Nút gửi (Full width trên điện thoại, kích thước vừa vặn trên desktop) -->
|
||||
<div class="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full sm:w-auto px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium text-sm rounded-lg shadow transition duration-200 text-center"
|
||||
>
|
||||
Gửi thông tin
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.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())
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.sisvietnamvn.web.plugins.googleForm;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.security.AdminContext;
|
||||
import com.sisvietnamvn.web.service.SettingService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/manage/plugins/google-form")
|
||||
public class googleFormAdminController {
|
||||
|
||||
private final SettingService settingService;
|
||||
private final AdminContext adminContext;
|
||||
private final ObjectMapper objectMapper;
|
||||
private static final String SETTING_KEY = googleFormPlugin.SETTING_KEY;
|
||||
|
||||
public googleFormAdminController(SettingService settingService, AdminContext adminContext, ObjectMapper objectMapper) {
|
||||
this.settingService = settingService;
|
||||
this.adminContext = adminContext;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String index(Model model) {
|
||||
if (!adminContext.currentUserCan("manage_options")) {
|
||||
return "error/403";
|
||||
}
|
||||
|
||||
String json = settingService.getValue(SETTING_KEY, "[]");
|
||||
List<GoogleFormItem> forms = parseFormsJson(json);
|
||||
|
||||
model.addAttribute("forms", forms);
|
||||
model.addAttribute("formsJson", json);
|
||||
model.addAttribute("pageTitle", "Quản lý Google Form & Form Tương Tác");
|
||||
|
||||
return "plugins/google-form/admin-settings";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String save(@RequestParam(value = "formData", required = false) String formDataJson, RedirectAttributes redirectAttributes) {
|
||||
if (!adminContext.currentUserCan("manage_options")) {
|
||||
return "error/403";
|
||||
}
|
||||
|
||||
if (formDataJson != null && !formDataJson.trim().isEmpty()) {
|
||||
try {
|
||||
objectMapper.readValue(formDataJson, new TypeReference<List<GoogleFormItem>>() {});
|
||||
settingService.setValue(SETTING_KEY, formDataJson);
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Đã lưu cấu hình Google Form thành công!");
|
||||
} catch (JsonProcessingException e) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Dữ liệu JSON không hợp lệ: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
settingService.setValue(SETTING_KEY, "[]");
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Đã xóa toàn bộ cấu hình Google Form!");
|
||||
}
|
||||
|
||||
return "redirect:/manage/plugins/google-form";
|
||||
}
|
||||
|
||||
public List<GoogleFormItem> parseFormsJson(String json) {
|
||||
if (json == null || json.trim().isEmpty() || "[]".equals(json)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<List<GoogleFormItem>>() {});
|
||||
} catch (Exception e) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
public static class GoogleFormItem {
|
||||
private String id;
|
||||
private String name;
|
||||
private String title;
|
||||
private String subtitle;
|
||||
private String formType; // 'google_iframe', 'tri_an', 'contact'
|
||||
private String embedUrl;
|
||||
private String height;
|
||||
private String theme; // 'blue_gradient', 'red_gradient', 'teal_gradient', 'light'
|
||||
private String buttonText;
|
||||
private String successMsg;
|
||||
|
||||
public GoogleFormItem() {}
|
||||
|
||||
public GoogleFormItem(String id, String name, String title, String subtitle, String formType, String embedUrl, String height, String theme, String buttonText, String successMsg) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
this.formType = formType;
|
||||
this.embedUrl = embedUrl;
|
||||
this.height = height;
|
||||
this.theme = theme;
|
||||
this.buttonText = buttonText;
|
||||
this.successMsg = successMsg;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public String getSubtitle() { return subtitle; }
|
||||
public void setSubtitle(String subtitle) { this.subtitle = subtitle; }
|
||||
public String getFormType() { return formType; }
|
||||
public void setFormType(String formType) { this.formType = formType; }
|
||||
public String getEmbedUrl() { return embedUrl; }
|
||||
public void setEmbedUrl(String embedUrl) { this.embedUrl = embedUrl; }
|
||||
public String getHeight() { return height; }
|
||||
public void setHeight(String height) { this.height = height; }
|
||||
public String getTheme() { return theme; }
|
||||
public void setTheme(String theme) { this.theme = theme; }
|
||||
public String getButtonText() { return buttonText; }
|
||||
public void setButtonText(String buttonText) { this.buttonText = buttonText; }
|
||||
public String getSuccessMsg() { return successMsg; }
|
||||
public void setSuccessMsg(String successMsg) { this.successMsg = successMsg; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package com.sisvietnamvn.web.plugins.googleForm;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.domain.Plugin;
|
||||
import com.sisvietnamvn.web.domain.PluginStatus;
|
||||
import com.sisvietnamvn.web.hook.HookManager;
|
||||
import com.sisvietnamvn.web.repository.ComponentTemplateRepository;
|
||||
import com.sisvietnamvn.web.repository.HtmlSnippetRepository;
|
||||
import com.sisvietnamvn.web.repository.PluginRepository;
|
||||
import com.sisvietnamvn.web.service.SettingService;
|
||||
|
||||
@Component
|
||||
public class googleFormPlugin {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(googleFormPlugin.class);
|
||||
private static final String PLUGIN_KEY = "google-form-plugin";
|
||||
public static final String SETTING_KEY = "plugin_google_form_data";
|
||||
|
||||
private final PluginRepository pluginRepository;
|
||||
private final HtmlSnippetRepository snippetRepository;
|
||||
private final ComponentTemplateRepository templateRepository;
|
||||
private final HookManager hookManager;
|
||||
private final SettingService settingService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public googleFormPlugin(PluginRepository pluginRepository,
|
||||
HtmlSnippetRepository snippetRepository,
|
||||
ComponentTemplateRepository templateRepository,
|
||||
HookManager hookManager,
|
||||
SettingService settingService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.pluginRepository = pluginRepository;
|
||||
this.snippetRepository = snippetRepository;
|
||||
this.templateRepository = templateRepository;
|
||||
this.hookManager = hookManager;
|
||||
this.settingService = settingService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@Transactional
|
||||
public void onApplicationReady() {
|
||||
boolean isActive = false;
|
||||
try {
|
||||
Plugin p = pluginRepository.findByPluginKey(PLUGIN_KEY).orElseGet(() -> {
|
||||
Plugin newPlugin = new Plugin();
|
||||
newPlugin.setPluginKey(PLUGIN_KEY);
|
||||
newPlugin.setName("Google Form & Form Tương Tác Tri Ân");
|
||||
newPlugin.setVersion("1.0");
|
||||
newPlugin.setStatus(PluginStatus.ACTIVE);
|
||||
newPlugin.setAuthor("Antigravity");
|
||||
newPlugin.setDescription("Plugin Google Form nhúng iframe và Form tương tác tri ân với hỗ trợ shortcode [plugin:google-form id=\"...\"]");
|
||||
return pluginRepository.save(newPlugin);
|
||||
});
|
||||
isActive = p.getStatus() == PluginStatus.ACTIVE;
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Could not verify googleFormPlugin status: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
LOG.info("googleFormPlugin is inactive.");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.info("googleFormPlugin is active. Registering hooks...");
|
||||
|
||||
// Hook to add menu to Plugins dropdown
|
||||
hookManager.addFilter("admin_menu_plugins", (content, args) -> {
|
||||
String html = content instanceof String ? (String) content : "";
|
||||
html += "<a class=\"collapse-item\" href=\"/manage/plugins/google-form\">Google Form & Tri Ân</a>\n";
|
||||
return html;
|
||||
}, 10);
|
||||
|
||||
// Hook to replace shortcode in content & snippets
|
||||
hookManager.addFilter("the_content", (content, args) -> {
|
||||
String text = content instanceof String ? (String) content : "";
|
||||
return processShortcodes(text);
|
||||
}, 10);
|
||||
|
||||
hookManager.addFilter("snippet_content", (content, args) -> {
|
||||
String text = content instanceof String ? (String) content : "";
|
||||
return processShortcodes(text);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
private String processShortcodes(String text) {
|
||||
if (text == null || !text.contains("[plugin:google-form")) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Do NOT replace shortcodes inside raw Editor.js JSON strings to prevent JSON syntax corruption
|
||||
String trimmed = text.trim();
|
||||
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Pattern matches: [plugin:google-form] or [plugin:google-form id="xyz"] or [plugin:google-form url="https://..."]
|
||||
Pattern pattern = Pattern.compile("\\[plugin:google-form(?:\\s+(?:id|slug)=\"([^\"]+)\")?(?:\\s+url=\"([^\"]+)\")?\\s*\\]");
|
||||
Matcher matcher = pattern.matcher(text);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
while (matcher.find()) {
|
||||
String targetId = matcher.group(1);
|
||||
String directUrl = matcher.group(2);
|
||||
String replacement = generateGoogleFormHtml(targetId, directUrl);
|
||||
matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String generateGoogleFormHtml(String targetId, String directUrl) {
|
||||
// If direct Google Form URL is provided in shortcode
|
||||
if (directUrl != null && !directUrl.trim().isEmpty()) {
|
||||
return buildIframeHtml(directUrl.trim(), "650px", "blue_gradient");
|
||||
}
|
||||
|
||||
String json = settingService.getValue(SETTING_KEY, "[]");
|
||||
List<googleFormAdminController.GoogleFormItem> forms = new ArrayList<>();
|
||||
try {
|
||||
forms = objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to parse GoogleForm JSON: {}", e.getMessage());
|
||||
}
|
||||
|
||||
if (forms.isEmpty()) {
|
||||
googleFormAdminController.GoogleFormItem defaultForm = new googleFormAdminController.GoogleFormItem(
|
||||
"form-1",
|
||||
"Form Tri Ân S.I.S Cần Thơ",
|
||||
"Chia sẻ lời tri ân",
|
||||
"Gửi lời nhắn cảm ơn chân thành đến đội ngũ bác sĩ và nhân viên y tế",
|
||||
"tri_an",
|
||||
"",
|
||||
"650px",
|
||||
"red_gradient",
|
||||
"Gửi thông tin",
|
||||
"Gửi thông tin tri ân thành công!"
|
||||
);
|
||||
forms.add(defaultForm);
|
||||
}
|
||||
|
||||
// Find requested form or default to first form
|
||||
googleFormAdminController.GoogleFormItem selectedForm = forms.get(0);
|
||||
if (targetId != null && !targetId.trim().isEmpty()) {
|
||||
for (googleFormAdminController.GoogleFormItem f : forms) {
|
||||
if (f.getId() != null && targetId.trim().equalsIgnoreCase(f.getId())) {
|
||||
selectedForm = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String fType = selectedForm.getFormType() != null ? selectedForm.getFormType() : "tri_an";
|
||||
String theme = selectedForm.getTheme() != null ? selectedForm.getTheme() : "blue_gradient";
|
||||
|
||||
if ("google_iframe".equalsIgnoreCase(fType)) {
|
||||
String embedUrl = selectedForm.getEmbedUrl() != null ? selectedForm.getEmbedUrl().trim() : "";
|
||||
String height = selectedForm.getHeight() != null && !selectedForm.getHeight().trim().isEmpty() ? selectedForm.getHeight().trim() : "650px";
|
||||
return buildIframeHtml(embedUrl, height, theme);
|
||||
} else if ("contact".equalsIgnoreCase(fType)) {
|
||||
return buildContactFormHtml(selectedForm);
|
||||
} else {
|
||||
// Default: 'tri_an' layout (inspired by temp.html)
|
||||
return buildTriAnFormHtml(selectedForm);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildIframeHtml(String embedUrl, String height, String theme) {
|
||||
if (embedUrl == null || embedUrl.isEmpty()) {
|
||||
return "<div class='p-4 text-center text-muted bg-light rounded my-3'><p class='m-0 font-weight-bold'>Vui lòng nhập Google Form Embed URL.</p></div>";
|
||||
}
|
||||
String bgStyle = getThemeBackground(theme);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("<div class='sis-google-form-embed-wrapper p-3 p-sm-4 text-center my-4' style='border-radius:16px; background:").append(bgStyle).append(";'>");
|
||||
sb.append(" <iframe src='").append(escapeHtml(embedUrl)).append("' style='width:100%; border:none; border-radius:12px; background:#ffffff; box-shadow:0 10px 25px rgba(0,0,0,0.15); height:").append(escapeHtml(height)).append(";'></iframe>");
|
||||
sb.append("</div>");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildTriAnFormHtml(googleFormAdminController.GoogleFormItem formItem) {
|
||||
String title = formItem.getTitle() != null && !formItem.getTitle().isEmpty() ? formItem.getTitle() : "Chia sẻ lời tri ân";
|
||||
String subtitle = formItem.getSubtitle() != null ? formItem.getSubtitle() : "";
|
||||
String btnText = formItem.getButtonText() != null && !formItem.getButtonText().isEmpty() ? formItem.getButtonText() : "Gửi thông tin";
|
||||
String successMsg = formItem.getSuccessMsg() != null && !formItem.getSuccessMsg().isEmpty() ? formItem.getSuccessMsg() : "Gửi thông tin thành công!";
|
||||
String theme = formItem.getTheme() != null ? formItem.getTheme() : "blue_gradient";
|
||||
String headerColor = getThemeHeaderColor(theme);
|
||||
String btnStyle = getThemeBtnStyle(theme);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("<div class='sis-tri-an-container p-3 p-sm-4 p-md-5 d-flex align-items-center justify-content-center my-4' style='min-height:100%; border-radius:20px; background:").append(getThemeBackground(theme)).append(";'>");
|
||||
sb.append(" <div class='bg-white rounded-2xl shadow-2xl w-100 p-4 p-sm-4 p-md-5 my-auto' style='max-width:580px; border-radius:16px; box-shadow:0 20px 40px rgba(0,0,0,0.15); font-family:system-ui,-apple-system,sans-serif;'>");
|
||||
sb.append(" <h2 class='h4 font-weight-bold mb-3' style='color:").append(headerColor).append(";'>").append(escapeHtml(title)).append("</h2>");
|
||||
if (!subtitle.isEmpty()) {
|
||||
sb.append(" <p class='text-muted small mb-4'>").append(escapeHtml(subtitle)).append("</p>");
|
||||
}
|
||||
sb.append(" <form class='sis-tri-an-form' data-success-msg='").append(escapeHtml(successMsg)).append("' onsubmit='event.preventDefault(); alert(this.getAttribute(\"data-success-msg\")); this.reset();'>");
|
||||
sb.append(" <div class='mb-3'>");
|
||||
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Họ tên <span class='text-danger'>*</span></label>");
|
||||
sb.append(" <input type='text' name='fullName' required placeholder='Nhập họ tên' class='form-control form-control-lg border-gray-300 rounded-lg text-sm'/>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" <div class='row g-3 mb-3'>");
|
||||
sb.append(" <div class='col-12 col-sm-6'>");
|
||||
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Bạn là <span class='text-danger'>*</span></label>");
|
||||
sb.append(" <select name='role' required class='form-select form-control form-control-lg border-gray-300 rounded-lg text-sm bg-white text-gray-500'>");
|
||||
sb.append(" <option value='' disabled selected>Chọn vai trò</option>");
|
||||
sb.append(" <option value='bac-si'>Bác sĩ / Nhân viên y tế</option>");
|
||||
sb.append(" <option value='benh-nhan'>Bệnh nhân / Người nhà</option>");
|
||||
sb.append(" <option value='khac'>Khác</option>");
|
||||
sb.append(" </select>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" <div class='col-12 col-sm-6'>");
|
||||
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Chức vụ/ Nơi làm việc <span class='text-danger'>*</span></label>");
|
||||
sb.append(" <input type='text' name='workplace' required placeholder='Nhập chức vụ/ nơi làm việc' class='form-control form-control-lg border-gray-300 rounded-lg text-sm'/>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" <div class='mb-3'>");
|
||||
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Số điện thoại <span class='text-danger'>*</span></label>");
|
||||
sb.append(" <input type='tel' name='phone' required placeholder='Nhập số điện thoại' class='form-control form-control-lg border-gray-300 rounded-lg text-sm'/>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" <div class='mb-3'>");
|
||||
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Nội dung lời nhắn <span class='text-danger'>*</span></label>");
|
||||
sb.append(" <textarea name='message' rows='3' required placeholder='Nhập nội dung' class='form-control border-gray-300 rounded-lg text-sm' style='resize:none;'></textarea>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" <div class='mb-4'>");
|
||||
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Hình ảnh <span class='text-danger'>*</span></label>");
|
||||
sb.append(" <label class='d-flex flex-column align-items-center justify-content-center border border-2 border-dashed border-gray-300 rounded-lg cursor-pointer bg-light p-3 text-muted hover-border-primary' style='width: 110px; height: 110px; cursor: pointer;'>");
|
||||
sb.append(" <svg width='24' height='24' fill='none' stroke='currentColor' viewBox='0 0 24 24' class='mb-1'><path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M12 4v16m8-8H4'></path></svg>");
|
||||
sb.append(" <span class='small' style='font-size: 11px;'>Tải tệp</span>");
|
||||
sb.append(" <input type='file' name='image' accept='image/*' class='d-none' required onchange='if(this.files && this.files[0]){ this.previousElementSibling.textContent = this.files[0].name.length > 12 ? this.files[0].name.substring(0,9)+\"...\" : this.files[0].name; }'/>");
|
||||
sb.append(" </label>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" <div class='pt-2'>");
|
||||
sb.append(" <button type='submit' class='btn text-white font-weight-bold px-4 py-2 rounded-lg shadow-sm w-100 w-sm-auto' style='").append(btnStyle).append("'>").append(escapeHtml(btnText)).append("</button>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" </form>");
|
||||
sb.append(" </div>");
|
||||
sb.append("</div>");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildContactFormHtml(googleFormAdminController.GoogleFormItem formItem) {
|
||||
String title = formItem.getTitle() != null && !formItem.getTitle().isEmpty() ? formItem.getTitle() : "Form Liên hệ & Góp ý";
|
||||
String subtitle = formItem.getSubtitle() != null ? formItem.getSubtitle() : "";
|
||||
String btnText = formItem.getButtonText() != null && !formItem.getButtonText().isEmpty() ? formItem.getButtonText() : "Gửi thông tin";
|
||||
String successMsg = formItem.getSuccessMsg() != null && !formItem.getSuccessMsg().isEmpty() ? formItem.getSuccessMsg() : "Gửi thông tin thành công!";
|
||||
String theme = formItem.getTheme() != null ? formItem.getTheme() : "blue_gradient";
|
||||
String headerColor = getThemeHeaderColor(theme);
|
||||
String btnStyle = getThemeBtnStyle(theme);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("<div class='sis-contact-container p-3 p-sm-4 p-md-5 d-flex align-items-center justify-content-center my-4' style='min-height:100%; border-radius:20px; background:").append(getThemeBackground(theme)).append(";'>");
|
||||
sb.append(" <div class='bg-white rounded-2xl shadow-2xl w-100 p-4 p-sm-4 p-md-5 my-auto' style='max-width:580px; border-radius:16px; box-shadow:0 20px 40px rgba(0,0,0,0.15); font-family:system-ui,-apple-system,sans-serif;'>");
|
||||
sb.append(" <h2 class='h4 font-weight-bold mb-3' style='color:").append(headerColor).append(";'>").append(escapeHtml(title)).append("</h2>");
|
||||
if (!subtitle.isEmpty()) {
|
||||
sb.append(" <p class='text-muted small mb-4'>").append(escapeHtml(subtitle)).append("</p>");
|
||||
}
|
||||
sb.append(" <form class='sis-contact-form' data-success-msg='").append(escapeHtml(successMsg)).append("' onsubmit='event.preventDefault(); alert(this.getAttribute(\"data-success-msg\")); this.reset();'>");
|
||||
sb.append(" <div class='mb-3'>");
|
||||
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Họ và tên <span class='text-danger'>*</span></label>");
|
||||
sb.append(" <input type='text' name='fullName' required placeholder='Nhập họ và tên' class='form-control form-control-lg border-gray-300 rounded-lg text-sm'/>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" <div class='row g-3 mb-3'>");
|
||||
sb.append(" <div class='col-12 col-sm-6'>");
|
||||
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Email <span class='text-danger'>*</span></label>");
|
||||
sb.append(" <input type='email' name='email' required placeholder='email@example.com' class='form-control form-control-lg border-gray-300 rounded-lg text-sm'/>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" <div class='col-12 col-sm-6'>");
|
||||
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Số điện thoại</label>");
|
||||
sb.append(" <input type='tel' name='phone' placeholder='Nhập số điện thoại' class='form-control form-control-lg border-gray-300 rounded-lg text-sm'/>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" <div class='mb-3'>");
|
||||
sb.append(" <label class='form-label font-weight-bold text-secondary small mb-1'>Nội dung tin nhắn <span class='text-danger'>*</span></label>");
|
||||
sb.append(" <textarea name='message' rows='4' required placeholder='Nhập nội dung cần hỗ trợ...' class='form-control border-gray-300 rounded-lg text-sm' style='resize:none;'></textarea>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" <div class='pt-2'>");
|
||||
sb.append(" <button type='submit' class='btn text-white font-weight-bold px-4 py-2 rounded-lg shadow-sm w-100 w-sm-auto' style='").append(btnStyle).append("'>").append(escapeHtml(btnText)).append("</button>");
|
||||
sb.append(" </div>");
|
||||
sb.append(" </form>");
|
||||
sb.append(" </div>");
|
||||
sb.append("</div>");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String getThemeBackground(String theme) {
|
||||
if ("red_gradient".equalsIgnoreCase(theme)) {
|
||||
return "linear-gradient(135deg, #701a1a 0%, #d7193f 50%, #fff0f3 100%)";
|
||||
} else if ("teal_gradient".equalsIgnoreCase(theme)) {
|
||||
return "linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #f0fdfa 100%)";
|
||||
} else if ("light".equalsIgnoreCase(theme)) {
|
||||
return "#f1f5f9";
|
||||
} else {
|
||||
return "linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #eff6ff 100%)";
|
||||
}
|
||||
}
|
||||
|
||||
private String getThemeHeaderColor(String theme) {
|
||||
if ("red_gradient".equalsIgnoreCase(theme)) return "#881C1C";
|
||||
if ("teal_gradient".equalsIgnoreCase(theme)) return "#0f766e";
|
||||
if ("light".equalsIgnoreCase(theme)) return "#1e293b";
|
||||
return "#1e3a8a";
|
||||
}
|
||||
|
||||
private String getThemeBtnStyle(String theme) {
|
||||
if ("red_gradient".equalsIgnoreCase(theme)) return "background:#d7193f; color:#fff;";
|
||||
if ("teal_gradient".equalsIgnoreCase(theme)) return "background:#0d9488; color:#fff;";
|
||||
if ("light".equalsIgnoreCase(theme)) return "background:#1e293b; color:#fff;";
|
||||
return "background:#2563eb; color:#fff;";
|
||||
}
|
||||
|
||||
private String escapeHtml(String text) {
|
||||
if (text == null) return "";
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* SISCardsTool — Dedicated Practice Cards / Content Cards Block Tool for Editor.js
|
||||
* Creates responsive practice cards matching the design from Untitled-5.html.
|
||||
*/
|
||||
class SISCardsTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Cards / Practice Cards',
|
||||
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="2"></rect><line x1="7" y1="8" x2="17" y2="8"></line><line x1="7" y1="12" x2="13" y2="12"></line><circle cx="16" cy="16" r="1.5"></circle></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly, block }) {
|
||||
this.api = api;
|
||||
this.blockAPI = block;
|
||||
this.readOnly = readOnly;
|
||||
this.instanceId = 'cards_' + Math.random().toString(36).substring(7);
|
||||
this.data = {
|
||||
cols: parseInt(data && data.cols) || 3,
|
||||
globalClass: (data && data.globalClass) || 'sis-practice-card-section',
|
||||
globalId: (data && data.globalId) || '',
|
||||
globalAttributes: (data && data.globalAttributes) || (data && data.attributes) || '',
|
||||
items: (data && data.items && Array.isArray(data.items)) ? data.items : []
|
||||
};
|
||||
|
||||
// Add initial default item if list is empty
|
||||
if (this.data.items.length === 0) {
|
||||
this.data.items.push({
|
||||
title: 'Hộ sinh: Danh sách người thực hành',
|
||||
date: '22/06/2026',
|
||||
linkUrl: '#',
|
||||
linkText: 'Tìm hiểu thêm',
|
||||
linkTarget: '_self',
|
||||
active: true
|
||||
});
|
||||
}
|
||||
this.wrapper = undefined;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.wrapper = document.createElement('div');
|
||||
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-cards-tool-wrapper';
|
||||
this.wrapper.style.fontFamily = 'inherit';
|
||||
|
||||
const titleLabel = document.createElement('div');
|
||||
titleLabel.className = 'font-weight-bold text-primary small mb-3';
|
||||
titleLabel.innerHTML = '<i class="fas fa-id-card"></i> Practice Cards Settings';
|
||||
this.wrapper.appendChild(titleLabel);
|
||||
|
||||
// Global Settings Row
|
||||
const settingsRow = document.createElement('div');
|
||||
settingsRow.className = 'form-row mb-3 pb-2 border-bottom';
|
||||
|
||||
// 1. Cols selector
|
||||
const colDiv = document.createElement('div');
|
||||
colDiv.className = 'col-md-3 mb-2';
|
||||
colDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Columns per Row</label>';
|
||||
const colSelect = document.createElement('select');
|
||||
colSelect.className = 'form-control form-control-sm';
|
||||
[1, 2, 3, 4, 6].forEach(num => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = num;
|
||||
opt.textContent = `${num} Column${num > 1 ? 's' : ''}`;
|
||||
if (num === this.data.cols) opt.selected = true;
|
||||
colSelect.appendChild(opt);
|
||||
});
|
||||
if (this.readOnly) colSelect.disabled = true;
|
||||
colSelect.addEventListener('change', (e) => {
|
||||
this.data.cols = parseInt(e.target.value) || 3;
|
||||
});
|
||||
this.colsSelect = colSelect;
|
||||
colDiv.appendChild(colSelect);
|
||||
settingsRow.appendChild(colDiv);
|
||||
|
||||
// 2. Global Class
|
||||
const classDiv = document.createElement('div');
|
||||
classDiv.className = 'col-md-3 mb-2';
|
||||
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Custom CSS Class</label>';
|
||||
const classInput = document.createElement('input');
|
||||
classInput.type = 'text';
|
||||
classInput.className = 'form-control form-control-sm';
|
||||
classInput.placeholder = 'e.g. sis-practice-card-section';
|
||||
classInput.value = this.data.globalClass;
|
||||
if (this.readOnly) classInput.disabled = true;
|
||||
this.globalClassInput = classInput;
|
||||
classDiv.appendChild(classInput);
|
||||
settingsRow.appendChild(classDiv);
|
||||
|
||||
// 3. Global ID
|
||||
const idDiv = document.createElement('div');
|
||||
idDiv.className = 'col-md-3 mb-2';
|
||||
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Custom Element ID</label>';
|
||||
const idInput = document.createElement('input');
|
||||
idInput.type = 'text';
|
||||
idInput.className = 'form-control form-control-sm';
|
||||
idInput.placeholder = 'e.g. practice-cards-list';
|
||||
idInput.value = this.data.globalId;
|
||||
if (this.readOnly) idInput.disabled = true;
|
||||
this.globalIdInput = idInput;
|
||||
idDiv.appendChild(idInput);
|
||||
settingsRow.appendChild(idDiv);
|
||||
|
||||
// 4. Custom Attributes
|
||||
const attrDiv = document.createElement('div');
|
||||
attrDiv.className = 'col-md-3 mb-2';
|
||||
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Custom Attributes</label>';
|
||||
const attrInput = document.createElement('input');
|
||||
attrInput.type = 'text';
|
||||
attrInput.className = 'form-control form-control-sm';
|
||||
attrInput.placeholder = 'data-aos="fade-up"';
|
||||
attrInput.value = this.data.globalAttributes;
|
||||
if (this.readOnly) attrInput.disabled = true;
|
||||
this.globalAttributesInput = attrInput;
|
||||
attrDiv.appendChild(attrInput);
|
||||
settingsRow.appendChild(attrDiv);
|
||||
|
||||
this.wrapper.appendChild(settingsRow);
|
||||
|
||||
// Items Container
|
||||
this.itemsContainer = document.createElement('div');
|
||||
this.itemsContainer.className = 'cards-items-container';
|
||||
this.wrapper.appendChild(this.itemsContainer);
|
||||
|
||||
this.renderItems();
|
||||
|
||||
// Add Card Button
|
||||
if (!this.readOnly) {
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.className = 'btn btn-sm btn-outline-primary mt-2 font-weight-bold';
|
||||
addBtn.innerHTML = '<i class="fas fa-plus mr-1"></i> Add Card Item';
|
||||
addBtn.addEventListener('click', () => {
|
||||
this.data.items.push({
|
||||
title: 'Bác sĩ y khoa: Danh sách người thực hành',
|
||||
date: new Date().toLocaleDateString('vi-VN'),
|
||||
linkUrl: '#',
|
||||
linkText: 'Tìm hiểu thêm',
|
||||
linkTarget: '_self',
|
||||
active: true
|
||||
});
|
||||
this.renderItems();
|
||||
});
|
||||
this.wrapper.appendChild(addBtn);
|
||||
}
|
||||
|
||||
return this.wrapper;
|
||||
}
|
||||
|
||||
renderItems() {
|
||||
this.itemsContainer.innerHTML = '';
|
||||
this.data.items.forEach((item, index) => {
|
||||
const itemBox = document.createElement('div');
|
||||
itemBox.className = 'card mb-2 shadow-sm border-secondary';
|
||||
itemBox.style.borderRadius = '8px';
|
||||
|
||||
const cardHeader = document.createElement('div');
|
||||
cardHeader.className = 'card-header bg-white py-2 px-3 d-flex align-items-center justify-content-between cursor-pointer';
|
||||
cardHeader.innerHTML = `
|
||||
<span class="font-weight-bold text-dark small">
|
||||
<i class="fas fa-id-card text-primary mr-1"></i> Card #${index + 1}: ${this.escapeHtml(item.title || 'Untitled Card')}
|
||||
</span>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-link text-secondary p-0 mr-2 toggle-btn" title="Toggle Edit"><i class="fas fa-chevron-down"></i></button>
|
||||
${index > 0 ? `<button type="button" class="btn btn-link text-info p-0 mr-2 move-up-btn" title="Move Up"><i class="fas fa-arrow-up"></i></button>` : ''}
|
||||
${index < this.data.items.length - 1 ? `<button type="button" class="btn btn-link text-info p-0 mr-2 move-down-btn" title="Move Down"><i class="fas fa-arrow-down"></i></button>` : ''}
|
||||
${this.data.items.length > 1 ? `<button type="button" class="btn btn-link text-danger p-0 delete-btn" title="Delete Card"><i class="fas fa-trash"></i></button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const cardBody = document.createElement('div');
|
||||
cardBody.className = 'card-body p-3';
|
||||
|
||||
// Inputs
|
||||
const row1 = document.createElement('div');
|
||||
row1.className = 'form-row mb-2';
|
||||
|
||||
// Title
|
||||
const titleCol = document.createElement('div');
|
||||
titleCol.className = 'col-md-8 mb-2';
|
||||
titleCol.innerHTML = '<label class="small font-weight-bold mb-1">Card Title</label>';
|
||||
const titleInp = document.createElement('input');
|
||||
titleInp.type = 'text';
|
||||
titleInp.className = 'form-control form-control-sm';
|
||||
titleInp.value = item.title || '';
|
||||
titleInp.placeholder = 'e.g. Hộ sinh: Danh sách người thực hành';
|
||||
if (this.readOnly) titleInp.disabled = true;
|
||||
titleInp.addEventListener('input', (e) => {
|
||||
item.title = e.target.value;
|
||||
cardHeader.querySelector('.font-weight-bold').innerHTML = `<i class="fas fa-id-card text-primary mr-1"></i> Card #${index + 1}: ${this.escapeHtml(item.title || 'Untitled Card')}`;
|
||||
});
|
||||
titleCol.appendChild(titleInp);
|
||||
row1.appendChild(titleCol);
|
||||
|
||||
// Date
|
||||
const dateCol = document.createElement('div');
|
||||
dateCol.className = 'col-md-4 mb-2';
|
||||
dateCol.innerHTML = '<label class="small font-weight-bold mb-1">Date</label>';
|
||||
const dateInp = document.createElement('input');
|
||||
dateInp.type = 'text';
|
||||
dateInp.className = 'form-control form-control-sm';
|
||||
dateInp.value = item.date || '';
|
||||
dateInp.placeholder = 'e.g. 22/06/2026';
|
||||
if (this.readOnly) dateInp.disabled = true;
|
||||
dateInp.addEventListener('input', (e) => {
|
||||
item.date = e.target.value;
|
||||
});
|
||||
dateCol.appendChild(dateInp);
|
||||
row1.appendChild(dateCol);
|
||||
|
||||
const row2 = document.createElement('div');
|
||||
row2.className = 'form-row mb-2';
|
||||
|
||||
// Link URL
|
||||
const urlCol = document.createElement('div');
|
||||
urlCol.className = 'col-md-6 mb-2';
|
||||
urlCol.innerHTML = '<label class="small font-weight-bold mb-1">Link URL</label>';
|
||||
const urlInp = document.createElement('input');
|
||||
urlInp.type = 'text';
|
||||
urlInp.className = 'form-control form-control-sm';
|
||||
urlInp.value = item.linkUrl || '';
|
||||
urlInp.placeholder = 'e.g. /documents/list.pdf or #';
|
||||
if (this.readOnly) urlInp.disabled = true;
|
||||
urlInp.addEventListener('input', (e) => {
|
||||
item.linkUrl = e.target.value;
|
||||
});
|
||||
urlCol.appendChild(urlInp);
|
||||
row2.appendChild(urlCol);
|
||||
|
||||
// Link Text
|
||||
const textCol = document.createElement('div');
|
||||
textCol.className = 'col-md-4 mb-2';
|
||||
textCol.innerHTML = '<label class="small font-weight-bold mb-1">Link Text</label>';
|
||||
const textInp = document.createElement('input');
|
||||
textInp.type = 'text';
|
||||
textInp.className = 'form-control form-control-sm';
|
||||
textInp.value = item.linkText || 'Tìm hiểu thêm';
|
||||
textInp.placeholder = 'e.g. Tìm hiểu thêm';
|
||||
if (this.readOnly) textInp.disabled = true;
|
||||
textInp.addEventListener('input', (e) => {
|
||||
item.linkText = e.target.value;
|
||||
});
|
||||
textCol.appendChild(textInp);
|
||||
row2.appendChild(textCol);
|
||||
|
||||
// Link Target
|
||||
const targetCol = document.createElement('div');
|
||||
targetCol.className = 'col-md-2 mb-2';
|
||||
targetCol.innerHTML = '<label class="small font-weight-bold mb-1">Target</label>';
|
||||
const targetSelect = document.createElement('select');
|
||||
targetSelect.className = 'form-control form-control-sm';
|
||||
|
||||
const optSelf = document.createElement('option');
|
||||
optSelf.value = '_self';
|
||||
optSelf.textContent = 'Same Tab (_self)';
|
||||
if ((item.linkTarget || '_self') === '_self') optSelf.selected = true;
|
||||
targetSelect.appendChild(optSelf);
|
||||
|
||||
const optBlank = document.createElement('option');
|
||||
optBlank.value = '_blank';
|
||||
optBlank.textContent = 'New Tab (_blank)';
|
||||
if (item.linkTarget === '_blank') optBlank.selected = true;
|
||||
targetSelect.appendChild(optBlank);
|
||||
|
||||
if (this.readOnly) targetSelect.disabled = true;
|
||||
targetSelect.addEventListener('change', (e) => {
|
||||
item.linkTarget = e.target.value;
|
||||
});
|
||||
targetCol.appendChild(targetSelect);
|
||||
row2.appendChild(targetCol);
|
||||
|
||||
cardBody.appendChild(row1);
|
||||
cardBody.appendChild(row2);
|
||||
|
||||
itemBox.appendChild(cardHeader);
|
||||
itemBox.appendChild(cardBody);
|
||||
this.itemsContainer.appendChild(itemBox);
|
||||
|
||||
// Event Listeners for action buttons
|
||||
const toggleBtn = cardHeader.querySelector('.toggle-btn');
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isHidden = cardBody.style.display === 'none';
|
||||
cardBody.style.display = isHidden ? 'block' : 'none';
|
||||
toggleBtn.querySelector('i').className = isHidden ? 'fas fa-chevron-down' : 'fas fa-chevron-right';
|
||||
});
|
||||
|
||||
const moveUpBtn = cardHeader.querySelector('.move-up-btn');
|
||||
if (moveUpBtn) {
|
||||
moveUpBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const temp = this.data.items[index];
|
||||
this.data.items[index] = this.data.items[index - 1];
|
||||
this.data.items[index - 1] = temp;
|
||||
this.renderItems();
|
||||
});
|
||||
}
|
||||
|
||||
const moveDownBtn = cardHeader.querySelector('.move-down-btn');
|
||||
if (moveDownBtn) {
|
||||
moveDownBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const temp = this.data.items[index];
|
||||
this.data.items[index] = this.data.items[index + 1];
|
||||
this.data.items[index + 1] = temp;
|
||||
this.renderItems();
|
||||
});
|
||||
}
|
||||
|
||||
const deleteBtn = cardHeader.querySelector('.delete-btn');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('Are you sure you want to remove this card?')) {
|
||||
this.data.items.splice(index, 1);
|
||||
this.renderItems();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return text.replace(/[&<>"']/g, function(m) {
|
||||
return {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[m];
|
||||
});
|
||||
}
|
||||
|
||||
save() {
|
||||
return {
|
||||
cols: this.colsSelect ? parseInt(this.colsSelect.value) || 3 : this.data.cols,
|
||||
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || 'sis-practice-card-section'),
|
||||
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
|
||||
globalAttributes: this.globalAttributesInput ? this.globalAttributesInput.value.trim() : (this.data.globalAttributes || ''),
|
||||
items: this.data.items
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Register Plugin
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['cards'] = { class: SISCardsTool, inlineToolbar: true };
|
||||
window.SISEditorPlugins['practiceCards'] = { class: SISCardsTool, inlineToolbar: true };
|
||||
window.SISEditorPlugins['practice-cards'] = { class: SISCardsTool, inlineToolbar: true };
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* CMS Plugin Shortcode Tool for Editor.js (SIS Vietnam)
|
||||
* 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 = '<i class="fas fa-cubes mr-1"></i> 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 = `
|
||||
<option value="">-- Chọn Shortcode Plugin --</option>
|
||||
@@ -62,6 +66,8 @@ class CMSPluginTool {
|
||||
<option value='[plugin:recruiting-table id="recruiting_2"]'>Bảng Tuyển dụng theo ID [plugin:recruiting-table id="recruiting_2"]</option>
|
||||
<option value="[plugin:price-table]">Bảng giá Mặc định [plugin:price-table]</option>
|
||||
<option value='[plugin:price-table id="table_1"]'>Bảng giá theo ID [plugin:price-table id="table_1"]</option>
|
||||
<option value='[plugin:google-form]'>Form Tri Ân Mặc định [plugin:google-form]</option>
|
||||
<option value='[plugin:google-form id="form-1"]'>Google Form / Tri Ân theo ID [plugin:google-form id="form-1"]</option>
|
||||
<option value="custom">-- Nhập Shortcode Tùy chỉnh --</option>
|
||||
`;
|
||||
|
||||
@@ -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 = '<i class="fas fa-sliders-h mr-1"></i> Tùy chỉnh CSS Class, Element ID, Style & Attribute';
|
||||
this.wrapper.appendChild(attrHeading);
|
||||
|
||||
const row1 = document.createElement('div');
|
||||
row1.className = 'form-row mb-2';
|
||||
|
||||
// CSS Class Input
|
||||
const classCol = document.createElement('div');
|
||||
classCol.className = 'col-md-6 mb-2';
|
||||
classCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">CSS Class (class)</label>';
|
||||
this.cssClassInput = document.createElement('input');
|
||||
this.cssClassInput.type = 'text';
|
||||
this.cssClassInput.className = 'form-control form-control-sm';
|
||||
this.cssClassInput.placeholder = 'e.g. custom-cms-wrapper py-4';
|
||||
this.cssClassInput.value = this.data.cssClass || '';
|
||||
if (this.readOnly) this.cssClassInput.disabled = true;
|
||||
classCol.appendChild(this.cssClassInput);
|
||||
|
||||
// Element ID Input
|
||||
const idCol = document.createElement('div');
|
||||
idCol.className = 'col-md-6 mb-2';
|
||||
idCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Element ID (id)</label>';
|
||||
this.elementIdInput = document.createElement('input');
|
||||
this.elementIdInput.type = 'text';
|
||||
this.elementIdInput.className = 'form-control form-control-sm';
|
||||
this.elementIdInput.placeholder = 'e.g. section-cms-plugin-1';
|
||||
this.elementIdInput.value = this.data.elementId || '';
|
||||
if (this.readOnly) this.elementIdInput.disabled = true;
|
||||
idCol.appendChild(this.elementIdInput);
|
||||
|
||||
row1.appendChild(classCol);
|
||||
row1.appendChild(idCol);
|
||||
this.wrapper.appendChild(row1);
|
||||
|
||||
const row2 = document.createElement('div');
|
||||
row2.className = 'form-row';
|
||||
|
||||
// Custom Style Input
|
||||
const styleCol = document.createElement('div');
|
||||
styleCol.className = 'col-md-6 mb-2';
|
||||
styleCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Custom Style (style)</label>';
|
||||
this.customStyleInput = document.createElement('input');
|
||||
this.customStyleInput.type = 'text';
|
||||
this.customStyleInput.className = 'form-control form-control-sm';
|
||||
this.customStyleInput.placeholder = 'e.g. background: #f8fafc; border-radius: 12px;';
|
||||
this.customStyleInput.value = this.data.customStyle || '';
|
||||
if (this.readOnly) this.customStyleInput.disabled = true;
|
||||
styleCol.appendChild(this.customStyleInput);
|
||||
|
||||
// Custom Attributes Input
|
||||
const attrsCol = document.createElement('div');
|
||||
attrsCol.className = 'col-md-6 mb-2';
|
||||
attrsCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Custom Attributes</label>';
|
||||
this.customAttrsInput = document.createElement('input');
|
||||
this.customAttrsInput.type = 'text';
|
||||
this.customAttrsInput.className = 'form-control form-control-sm';
|
||||
this.customAttrsInput.placeholder = 'e.g. data-aos="fade-up" data-ref="123"';
|
||||
this.customAttrsInput.value = this.data.customAttrs || '';
|
||||
if (this.readOnly) this.customAttrsInput.disabled = true;
|
||||
attrsCol.appendChild(this.customAttrsInput);
|
||||
|
||||
row2.appendChild(styleCol);
|
||||
row2.appendChild(attrsCol);
|
||||
this.wrapper.appendChild(row2);
|
||||
|
||||
return this.wrapper;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 = '<label class="small font-weight-bold text-secondary mb-1">Flex Custom Style</label>';
|
||||
const styleInput = document.createElement('input');
|
||||
styleInput.type = 'text';
|
||||
styleInput.className = 'form-control form-control-sm';
|
||||
styleInput.placeholder = 'e.g. color: red; background: #fff;';
|
||||
styleInput.value = this.data.globalStyle || '';
|
||||
if (this.readOnly) styleInput.disabled = true;
|
||||
styleInput.addEventListener('input', (e) => this.data.globalStyle = e.target.value.trim());
|
||||
this.globalStyleInput = styleInput;
|
||||
styleDiv.appendChild(styleInput);
|
||||
bgSettingsRow.appendChild(styleDiv);
|
||||
|
||||
// 5. Global Custom Attributes
|
||||
const attrDiv = document.createElement('div');
|
||||
attrDiv.className = 'col-md-3 mb-2';
|
||||
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Custom Attributes</label>';
|
||||
@@ -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++) {
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* SISGoogleFormTool — Form & Tri Ân Block Plugin for Editor.js
|
||||
* Allows content managers to build interactive forms (e.g., "Chia sẻ lời tri ân", Contact forms)
|
||||
* or embed Google Forms / external form iFrames with live preview and theme options.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
class SISGoogleFormTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Form & Tri Ân',
|
||||
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
formType: (data && data.formType) ? data.formType : 'tri_an',
|
||||
title: (data && data.title) ? data.title : 'Chia sẻ lời tri ân',
|
||||
subtitle: (data && data.subtitle) ? data.subtitle : '',
|
||||
buttonText: (data && data.buttonText) ? data.buttonText : 'Gửi thông tin',
|
||||
successMsg: (data && data.successMsg) ? data.successMsg : 'Gửi thông tin thành công!',
|
||||
actionUrl: (data && data.actionUrl) ? data.actionUrl : '',
|
||||
iframeUrl: (data && data.iframeUrl) ? data.iframeUrl : '',
|
||||
iframeHeight: (data && data.iframeHeight) ? data.iframeHeight : '650px',
|
||||
theme: (data && data.theme) ? data.theme : 'blue_gradient',
|
||||
fullWidth: !!(data && (data.fullWidth || data.stretched))
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const container = document.createElement('div');
|
||||
container.style.border = '1px solid #e3e6f0';
|
||||
container.style.borderRadius = '8px';
|
||||
container.style.padding = '14px';
|
||||
container.style.background = '#fff';
|
||||
container.style.marginBottom = '12px';
|
||||
|
||||
const headerLabel = document.createElement('label');
|
||||
headerLabel.className = 'font-weight-bold text-primary small mb-2 d-block';
|
||||
headerLabel.innerHTML = '<i class="fas fa-wpforms mr-1"></i> Interactive Form & Google Form Block Settings';
|
||||
container.appendChild(headerLabel);
|
||||
|
||||
// Row 1: Form Type & Theme
|
||||
const row1 = document.createElement('div');
|
||||
row1.className = 'form-row mb-2';
|
||||
|
||||
const typeCol = document.createElement('div');
|
||||
typeCol.className = 'col-md-6 mb-2';
|
||||
typeCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Form Type / Template</label>';
|
||||
const typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'form-control form-control-sm';
|
||||
typeSelect.innerHTML = `
|
||||
<option value="tri_an" ${this.data.formType === 'tri_an' ? 'selected' : ''}>Form Chia Sẻ Lời Tri Ân (Tương tác)</option>
|
||||
<option value="contact" ${this.data.formType === 'contact' ? 'selected' : ''}>Form Liên Hệ & Góp Ý (Tương tác)</option>
|
||||
<option value="google_iframe" ${this.data.formType === 'google_iframe' ? 'selected' : ''}>Google Form / External iFrame Embed</option>
|
||||
`;
|
||||
typeCol.appendChild(typeSelect);
|
||||
|
||||
const themeCol = document.createElement('div');
|
||||
themeCol.className = 'col-md-6 mb-2';
|
||||
themeCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Color Theme</label>';
|
||||
const themeSelect = document.createElement('select');
|
||||
themeSelect.className = 'form-control form-control-sm';
|
||||
themeSelect.innerHTML = `
|
||||
<option value="blue_gradient" ${this.data.theme === 'blue_gradient' ? 'selected' : ''}>🔵 Blue Gradient (Đại học Y Dược HCM)</option>
|
||||
<option value="red_gradient" ${this.data.theme === 'red_gradient' ? 'selected' : ''}>🔴 Red Gradient (S.I.S Cần Thơ)</option>
|
||||
<option value="teal_gradient" ${this.data.theme === 'teal_gradient' ? 'selected' : ''}>🟢 Teal Medical Gradient</option>
|
||||
<option value="light" ${this.data.theme === 'light' ? 'selected' : ''}>⚪ Light Minimal Card</option>
|
||||
`;
|
||||
themeCol.appendChild(themeSelect);
|
||||
|
||||
row1.appendChild(typeCol);
|
||||
row1.appendChild(themeCol);
|
||||
container.appendChild(row1);
|
||||
|
||||
// Controls container for interactive vs iframe modes
|
||||
const customFieldsContainer = document.createElement('div');
|
||||
|
||||
// Title & Subtitle inputs
|
||||
const row2 = document.createElement('div');
|
||||
row2.className = 'form-row mb-2';
|
||||
|
||||
const titleCol = document.createElement('div');
|
||||
titleCol.className = 'col-md-6 mb-2';
|
||||
titleCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Form Title</label>';
|
||||
this.titleInput = document.createElement('input');
|
||||
this.titleInput.type = 'text';
|
||||
this.titleInput.className = 'form-control form-control-sm';
|
||||
this.titleInput.placeholder = 'e.g. Chia sẻ lời tri ân';
|
||||
this.titleInput.value = this.data.title;
|
||||
titleCol.appendChild(this.titleInput);
|
||||
|
||||
const subtitleCol = document.createElement('div');
|
||||
subtitleCol.className = 'col-md-6 mb-2';
|
||||
subtitleCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Subtitle / Description (Optional)</label>';
|
||||
this.subtitleInput = document.createElement('input');
|
||||
this.subtitleInput.type = 'text';
|
||||
this.subtitleInput.className = 'form-control form-control-sm';
|
||||
this.subtitleInput.placeholder = 'e.g. Gửi lời nhắn đến các bác sĩ và nhân viên y tế...';
|
||||
this.subtitleInput.value = this.data.subtitle;
|
||||
subtitleCol.appendChild(this.subtitleInput);
|
||||
|
||||
row2.appendChild(titleCol);
|
||||
row2.appendChild(subtitleCol);
|
||||
customFieldsContainer.appendChild(row2);
|
||||
|
||||
// Button text, Success msg, Action URL
|
||||
const row3 = document.createElement('div');
|
||||
row3.className = 'form-row mb-2';
|
||||
|
||||
const btnCol = document.createElement('div');
|
||||
btnCol.className = 'col-md-4 mb-2';
|
||||
btnCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Submit Button Text</label>';
|
||||
this.btnTextInput = document.createElement('input');
|
||||
this.btnTextInput.type = 'text';
|
||||
this.btnTextInput.className = 'form-control form-control-sm';
|
||||
this.btnTextInput.placeholder = 'e.g. Gửi thông tin';
|
||||
this.btnTextInput.value = this.data.buttonText;
|
||||
btnCol.appendChild(this.btnTextInput);
|
||||
|
||||
const successCol = document.createElement('div');
|
||||
successCol.className = 'col-md-4 mb-2';
|
||||
successCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Success Alert Message</label>';
|
||||
this.successInput = document.createElement('input');
|
||||
this.successInput.type = 'text';
|
||||
this.successInput.className = 'form-control form-control-sm';
|
||||
this.successInput.placeholder = 'e.g. Gửi thông tin thành công!';
|
||||
this.successInput.value = this.data.successMsg;
|
||||
successCol.appendChild(this.successInput);
|
||||
|
||||
const actionCol = document.createElement('div');
|
||||
actionCol.className = 'col-md-4 mb-2';
|
||||
actionCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Form Submit API URL (Optional)</label>';
|
||||
this.actionUrlInput = document.createElement('input');
|
||||
this.actionUrlInput.type = 'text';
|
||||
this.actionUrlInput.className = 'form-control form-control-sm';
|
||||
this.actionUrlInput.placeholder = 'e.g. /api/tri-an/submit';
|
||||
this.actionUrlInput.value = this.data.actionUrl;
|
||||
actionCol.appendChild(this.actionUrlInput);
|
||||
|
||||
row3.appendChild(btnCol);
|
||||
row3.appendChild(successCol);
|
||||
row3.appendChild(actionCol);
|
||||
customFieldsContainer.appendChild(row3);
|
||||
|
||||
container.appendChild(customFieldsContainer);
|
||||
|
||||
// Google Form iFrame controls
|
||||
const iframeFieldsContainer = document.createElement('div');
|
||||
iframeFieldsContainer.className = 'form-row mb-2';
|
||||
iframeFieldsContainer.style.display = this.data.formType === 'google_iframe' ? '' : 'none';
|
||||
|
||||
const iframeUrlCol = document.createElement('div');
|
||||
iframeUrlCol.className = 'col-md-8 mb-2';
|
||||
iframeUrlCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Google Form Embed URL (src)</label>';
|
||||
this.iframeUrlInput = document.createElement('input');
|
||||
this.iframeUrlInput.type = 'text';
|
||||
this.iframeUrlInput.className = 'form-control form-control-sm';
|
||||
this.iframeUrlInput.placeholder = 'https://docs.google.com/forms/d/e/.../viewform?embedded=true';
|
||||
this.iframeUrlInput.value = this.data.iframeUrl;
|
||||
iframeUrlCol.appendChild(this.iframeUrlInput);
|
||||
|
||||
const iframeHeightCol = document.createElement('div');
|
||||
iframeHeightCol.className = 'col-md-4 mb-2';
|
||||
iframeHeightCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">iFrame Height (e.g. 650px, 800px)</label>';
|
||||
this.iframeHeightInput = document.createElement('input');
|
||||
this.iframeHeightInput.type = 'text';
|
||||
this.iframeHeightInput.className = 'form-control form-control-sm';
|
||||
this.iframeHeightInput.placeholder = '650px';
|
||||
this.iframeHeightInput.value = this.data.iframeHeight;
|
||||
iframeHeightCol.appendChild(this.iframeHeightInput);
|
||||
|
||||
iframeFieldsContainer.appendChild(iframeUrlCol);
|
||||
iframeFieldsContainer.appendChild(iframeHeightCol);
|
||||
container.appendChild(iframeFieldsContainer);
|
||||
|
||||
// Stretch toggle
|
||||
const stretchWrapper = document.createElement('div');
|
||||
stretchWrapper.className = 'custom-control custom-switch mb-3 mt-1';
|
||||
this.fullWidthCheck = document.createElement('input');
|
||||
this.fullWidthCheck.type = 'checkbox';
|
||||
this.fullWidthCheck.className = 'custom-control-input';
|
||||
this.fullWidthCheck.id = 'form_stretch_' + Math.random().toString(36).substring(7);
|
||||
this.fullWidthCheck.checked = !!this.data.fullWidth;
|
||||
const stretchLabel = document.createElement('label');
|
||||
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
|
||||
stretchLabel.htmlFor = this.fullWidthCheck.id;
|
||||
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h mr-1"></i> Stretch Form Container to Full Screen Width';
|
||||
this.fullWidthCheck.addEventListener('change', () => {
|
||||
this.data.fullWidth = this.fullWidthCheck.checked;
|
||||
});
|
||||
stretchWrapper.appendChild(this.fullWidthCheck);
|
||||
stretchWrapper.appendChild(stretchLabel);
|
||||
container.appendChild(stretchWrapper);
|
||||
|
||||
// Live Preview Box
|
||||
const previewLabel = document.createElement('div');
|
||||
previewLabel.className = 'small font-weight-bold text-secondary mb-2';
|
||||
previewLabel.innerHTML = '<i class="fas fa-eye mr-1"></i> Live Form Preview:';
|
||||
container.appendChild(previewLabel);
|
||||
|
||||
const previewBox = document.createElement('div');
|
||||
previewBox.style.cssText = 'border:1px dashed #cbd5e1; border-radius:12px; overflow:hidden; margin-bottom:10px; background:#f8fafc;';
|
||||
container.appendChild(previewBox);
|
||||
|
||||
const getThemeBackground = (themeKey) => {
|
||||
switch (themeKey) {
|
||||
case 'red_gradient':
|
||||
return 'linear-gradient(135deg, #701a1a 0%, #d7193f 50%, #fff0f3 100%)';
|
||||
case 'teal_gradient':
|
||||
return 'linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #f0fdfa 100%)';
|
||||
case 'light':
|
||||
return '#f1f5f9';
|
||||
case 'blue_gradient':
|
||||
default:
|
||||
return 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #eff6ff 100%)';
|
||||
}
|
||||
};
|
||||
|
||||
const getThemeHeaderColor = (themeKey) => {
|
||||
switch (themeKey) {
|
||||
case 'red_gradient': return '#881C1C';
|
||||
case 'teal_gradient': return '#0f766e';
|
||||
case 'light': return '#1e293b';
|
||||
case 'blue_gradient':
|
||||
default: return '#1e3a8a';
|
||||
}
|
||||
};
|
||||
|
||||
const getThemeBtnClass = (themeKey) => {
|
||||
switch (themeKey) {
|
||||
case 'red_gradient': return 'background:#d7193f; color:#fff;';
|
||||
case 'teal_gradient': return 'background:#0d9488; color:#fff;';
|
||||
case 'light': return 'background:#1e293b; color:#fff;';
|
||||
case 'blue_gradient':
|
||||
default: return 'background:#2563eb; color:#fff;';
|
||||
}
|
||||
};
|
||||
|
||||
const updatePreview = () => {
|
||||
const fType = typeSelect.value;
|
||||
const themeVal = themeSelect.value;
|
||||
const titleVal = this.titleInput.value.trim() || 'Chia sẻ lời tri ân';
|
||||
const subVal = this.subtitleInput.value.trim();
|
||||
const btnVal = this.btnTextInput.value.trim() || 'Gửi thông tin';
|
||||
const iframeUrlVal = this.iframeUrlInput.value.trim();
|
||||
const iframeHVal = this.iframeHeightInput.value.trim() || '650px';
|
||||
|
||||
this.data.formType = fType;
|
||||
this.data.theme = themeVal;
|
||||
|
||||
if (fType === 'google_iframe') {
|
||||
customFieldsContainer.style.display = 'none';
|
||||
iframeFieldsContainer.style.display = '';
|
||||
if (iframeUrlVal) {
|
||||
previewBox.innerHTML = `
|
||||
<div style="padding:15px; background:${getThemeBackground(themeVal)}; min-height:300px; display:flex; justify-content:center; align-items:center;">
|
||||
<iframe src="${iframeUrlVal}" style="width:100%; height:${iframeHVal}; border:none; border-radius:12px; background:#fff; box-shadow:0 10px 25px rgba(0,0,0,0.15);"></iframe>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
previewBox.innerHTML = `
|
||||
<div style="padding:40px 20px; text-align:center; color:#64748b;">
|
||||
<i class="fab fa-google fa-2x mb-2 text-primary" style="display:block;"></i>
|
||||
<p class="small font-weight-bold m-0">Vui lòng nhập Google Form Embed URL (src) để xem bản trước.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else if (fType === 'tri_an') {
|
||||
customFieldsContainer.style.display = '';
|
||||
iframeFieldsContainer.style.display = 'none';
|
||||
previewBox.innerHTML = `
|
||||
<div style="background:${getThemeBackground(themeVal)}; padding:24px 16px; min-height:100%; display:flex; justify-content:center; align-items:center;">
|
||||
<div style="background:#ffffff; border-radius:16px; box-shadow:0 20px 40px rgba(0,0,0,0.15); width:100%; max-width:540px; padding:24px; font-family:system-ui,-apple-system,sans-serif;">
|
||||
<h3 style="font-size:20px; font-weight:700; color:${getThemeHeaderColor(themeVal)}; margin-top:0; margin-bottom:${subVal ? '6px' : '18px'};">${titleVal}</h3>
|
||||
${subVal ? `<p style="font-size:13px; color:#64748b; margin-top:0; margin-bottom:18px;">${subVal}</p>` : ''}
|
||||
<div style="display:flex; flex-direction:column; gap:12px;">
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Họ tên <span style="color:#ef4444;">*</span></label>
|
||||
<input type="text" disabled placeholder="Nhập họ tên" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa;">
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Bạn là <span style="color:#ef4444;">*</span></label>
|
||||
<select disabled style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa; color:#6b7280;">
|
||||
<option>Chọn vai trò</option>
|
||||
<option>Bác sĩ / Nhân viên y tế</option>
|
||||
<option>Bệnh nhân / Người nhà</option>
|
||||
<option>Khác</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Chức vụ / Nơi làm việc <span style="color:#ef4444;">*</span></label>
|
||||
<input type="text" disabled placeholder="Nhập chức vụ/ nơi làm việc" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa;">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Số điện thoại <span style="color:#ef4444;">*</span></label>
|
||||
<input type="tel" disabled placeholder="Nhập số điện thoại" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Nội dung lời nhắn <span style="color:#ef4444;">*</span></label>
|
||||
<textarea disabled rows="3" placeholder="Nhập nội dung" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa; resize:none;"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Hình ảnh <span style="color:#ef4444;">*</span></label>
|
||||
<div style="width:90px; height:90px; border:2px dashed #d1d5db; border-radius:8px; display:flex; flex-direction:column; align-items:center; justify-content:center; background:#f9fafb; color:#9ca3af;">
|
||||
<i class="fas fa-plus mb-1"></i>
|
||||
<span style="font-size:11px;">Tải tệp</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding-top:6px;">
|
||||
<button disabled type="button" style="padding:10px 24px; border:none; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; ${getThemeBtnClass(themeVal)}">${btnVal}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (fType === 'contact') {
|
||||
customFieldsContainer.style.display = '';
|
||||
iframeFieldsContainer.style.display = 'none';
|
||||
previewBox.innerHTML = `
|
||||
<div style="background:${getThemeBackground(themeVal)}; padding:24px 16px; min-height:100%; display:flex; justify-content:center; align-items:center;">
|
||||
<div style="background:#ffffff; border-radius:16px; box-shadow:0 20px 40px rgba(0,0,0,0.15); width:100%; max-width:540px; padding:24px; font-family:system-ui,-apple-system,sans-serif;">
|
||||
<h3 style="font-size:20px; font-weight:700; color:${getThemeHeaderColor(themeVal)}; margin-top:0; margin-bottom:${subVal ? '6px' : '18px'};">${titleVal}</h3>
|
||||
${subVal ? `<p style="font-size:13px; color:#64748b; margin-top:0; margin-bottom:18px;">${subVal}</p>` : ''}
|
||||
<div style="display:flex; flex-direction:column; gap:12px;">
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Họ và tên <span style="color:#ef4444;">*</span></label>
|
||||
<input type="text" disabled placeholder="Nhập họ và tên của bạn" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa;">
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Email <span style="color:#ef4444;">*</span></label>
|
||||
<input type="email" disabled placeholder="email@example.com" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Số điện thoại</label>
|
||||
<input type="tel" disabled placeholder="Nhập số điện thoại" style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa;">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; font-weight:600; color:#374151; margin-bottom:4px;">Nội dung tin nhắn <span style="color:#ef4444;">*</span></label>
|
||||
<textarea disabled rows="3" placeholder="Nhập nội dung cần hỗ trợ hoặc góp ý..." style="width:100%; padding:8px 12px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; background:#fafafa; resize:none;"></textarea>
|
||||
</div>
|
||||
<div style="padding-top:6px;">
|
||||
<button disabled type="button" style="padding:10px 24px; border:none; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; ${getThemeBtnClass(themeVal)}">${btnVal}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
// Event Listeners
|
||||
typeSelect.addEventListener('change', updatePreview);
|
||||
themeSelect.addEventListener('change', updatePreview);
|
||||
|
||||
[this.titleInput, this.subtitleInput, this.btnTextInput, this.successInput, this.actionUrlInput, this.iframeUrlInput, this.iframeHeightInput].forEach(inp => {
|
||||
if (inp) {
|
||||
inp.addEventListener('input', updatePreview);
|
||||
if (this.readOnly) inp.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (this.readOnly) {
|
||||
typeSelect.disabled = true;
|
||||
themeSelect.disabled = true;
|
||||
this.fullWidthCheck.disabled = true;
|
||||
}
|
||||
|
||||
updatePreview();
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
save() {
|
||||
return {
|
||||
formType: this.data.formType || 'tri_an',
|
||||
title: this.titleInput ? this.titleInput.value : this.data.title,
|
||||
subtitle: this.subtitleInput ? this.subtitleInput.value : this.data.subtitle,
|
||||
buttonText: this.btnTextInput ? this.btnTextInput.value : this.data.buttonText,
|
||||
successMsg: this.successInput ? this.successInput.value : this.data.successMsg,
|
||||
actionUrl: this.actionUrlInput ? this.actionUrlInput.value : this.data.actionUrl,
|
||||
iframeUrl: this.iframeUrlInput ? this.iframeUrlInput.value : this.data.iframeUrl,
|
||||
iframeHeight: this.iframeHeightInput ? this.iframeHeightInput.value : this.data.iframeHeight,
|
||||
theme: this.data.theme || 'blue_gradient',
|
||||
fullWidth: this.fullWidthCheck ? this.fullWidthCheck.checked : !!this.data.fullWidth
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Register Plugin globally in window.SISEditorPlugins
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['googleForm'] = {
|
||||
class: SISGoogleFormTool
|
||||
};
|
||||
|
||||
window.SISGoogleFormTool = SISGoogleFormTool;
|
||||
})();
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* SISHeroBannerTool — Hero Banner Plugin for Editor.js
|
||||
* 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 = '<i class="fas fa-image"></i> 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 = '<i class="fas fa-arrows-alt-h"></i> Stretch Banner to Full Screen Width';
|
||||
|
||||
stretchCheck.addEventListener('change', () => {
|
||||
this.data.stretched = stretchCheck.checked;
|
||||
});
|
||||
stretchCheck.addEventListener('change', () => { this.data.stretched = stretchCheck.checked; });
|
||||
stretchWrapper.appendChild(stretchCheck);
|
||||
stretchWrapper.appendChild(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 = '<i class="fas fa-magic mr-1"></i> Gradient Overlay Settings';
|
||||
gradSection.appendChild(gradHeader);
|
||||
|
||||
// Row 1: type selector + opacity
|
||||
const gradRow1 = document.createElement('div');
|
||||
gradRow1.className = 'form-row align-items-end mb-2';
|
||||
|
||||
// Gradient type
|
||||
const gradTypeCol = document.createElement('div'); gradTypeCol.className = 'col-md-6 mb-2';
|
||||
const gradTypeLabel = document.createElement('label');
|
||||
gradTypeLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';
|
||||
gradTypeLabel.textContent = 'Gradient Type';
|
||||
const gradTypeSelect = document.createElement('select');
|
||||
gradTypeSelect.className = 'form-control form-control-sm';
|
||||
SISHeroBannerTool.GRADIENT_PRESETS.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.label;
|
||||
if (p.id === this.data.gradientType) opt.selected = true;
|
||||
gradTypeSelect.appendChild(opt);
|
||||
});
|
||||
gradTypeCol.appendChild(gradTypeLabel);
|
||||
gradTypeCol.appendChild(gradTypeSelect);
|
||||
gradRow1.appendChild(gradTypeCol);
|
||||
|
||||
// Opacity
|
||||
const gradOpCol = document.createElement('div'); gradOpCol.className = 'col-md-6 mb-2';
|
||||
const gradOpLabel = document.createElement('label');
|
||||
gradOpLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';
|
||||
const opValSpan = document.createElement('span');
|
||||
opValSpan.textContent = Math.round(parseFloat(this.data.overlayOpacity) * 100) + '%';
|
||||
gradOpLabel.innerHTML = 'Overlay Opacity — ';
|
||||
gradOpLabel.appendChild(opValSpan);
|
||||
const gradOpInput = document.createElement('input');
|
||||
gradOpInput.type = 'range';
|
||||
gradOpInput.className = 'form-control-range';
|
||||
gradOpInput.min = '0'; gradOpInput.max = '1'; gradOpInput.step = '0.05';
|
||||
gradOpInput.value = this.data.overlayOpacity;
|
||||
gradOpInput.addEventListener('input', () => {
|
||||
this.data.overlayOpacity = gradOpInput.value;
|
||||
opValSpan.textContent = Math.round(parseFloat(gradOpInput.value) * 100) + '%';
|
||||
updatePreview();
|
||||
});
|
||||
gradOpCol.appendChild(gradOpLabel);
|
||||
gradOpCol.appendChild(gradOpInput);
|
||||
gradRow1.appendChild(gradOpCol);
|
||||
gradSection.appendChild(gradRow1);
|
||||
|
||||
// Row 2: direction controls (angle or radial XY) + colour pickers
|
||||
const gradRow2 = document.createElement('div');
|
||||
gradRow2.className = 'form-row align-items-end mb-2';
|
||||
|
||||
// -- Linear angle --
|
||||
const angleCol = document.createElement('div'); angleCol.className = 'col-md-3 mb-2';
|
||||
const angleLabel = document.createElement('label');
|
||||
angleLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';
|
||||
const angleValSpan = document.createElement('span');
|
||||
angleValSpan.textContent = this.data.gradientAngle + '°';
|
||||
angleLabel.innerHTML = 'Direction — ';
|
||||
angleLabel.appendChild(angleValSpan);
|
||||
const angleInput = document.createElement('input');
|
||||
angleInput.type = 'range';
|
||||
angleInput.className = 'form-control-range';
|
||||
angleInput.min = '0'; angleInput.max = '360'; angleInput.step = '5';
|
||||
angleInput.value = this.data.gradientAngle;
|
||||
angleInput.addEventListener('input', () => {
|
||||
this.data.gradientAngle = parseInt(angleInput.value);
|
||||
angleValSpan.textContent = angleInput.value + '°';
|
||||
updatePreview();
|
||||
});
|
||||
// Visual angle wheel indicator
|
||||
const angleWheel = document.createElement('div');
|
||||
angleWheel.style.cssText = 'width:38px;height:38px;border-radius:50%;border:2px solid #4e73df;display:flex;align-items:center;justify-content:center;margin-top:4px;position:relative;background:#eef2ff;';
|
||||
const wheelArrow = document.createElement('div');
|
||||
wheelArrow.style.cssText = 'width:2px;height:14px;background:#4e73df;border-radius:2px;transform-origin:bottom center;position:absolute;bottom:50%;left:calc(50% - 1px);';
|
||||
const updateWheel = () => { wheelArrow.style.transform = `rotate(${this.data.gradientAngle}deg)`; };
|
||||
updateWheel();
|
||||
angleWheel.appendChild(wheelArrow);
|
||||
angleCol.appendChild(angleLabel);
|
||||
angleCol.appendChild(angleInput);
|
||||
angleCol.appendChild(angleWheel);
|
||||
gradRow2.appendChild(angleCol);
|
||||
|
||||
// -- Radial X/Y --
|
||||
const radialXCol = document.createElement('div'); radialXCol.className = 'col-md-2 mb-2';
|
||||
const radialXLabel = document.createElement('label');
|
||||
radialXLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';
|
||||
const radXSpan = document.createElement('span'); radXSpan.textContent = this.data.gradientRadialX + '%';
|
||||
radialXLabel.innerHTML = 'Radial X — '; radialXLabel.appendChild(radXSpan);
|
||||
const radialXInput = document.createElement('input');
|
||||
radialXInput.type = 'range'; radialXInput.className = 'form-control-range';
|
||||
radialXInput.min = '0'; radialXInput.max = '100'; radialXInput.step = '5';
|
||||
radialXInput.value = this.data.gradientRadialX;
|
||||
radialXInput.addEventListener('input', () => {
|
||||
this.data.gradientRadialX = parseInt(radialXInput.value);
|
||||
radXSpan.textContent = radialXInput.value + '%';
|
||||
updatePreview();
|
||||
});
|
||||
radialXCol.appendChild(radialXLabel); radialXCol.appendChild(radialXInput);
|
||||
|
||||
const radialYCol = document.createElement('div'); radialYCol.className = 'col-md-2 mb-2';
|
||||
const radialYLabel = document.createElement('label');
|
||||
radialYLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';
|
||||
const radYSpan = document.createElement('span'); radYSpan.textContent = this.data.gradientRadialY + '%';
|
||||
radialYLabel.innerHTML = 'Radial Y — '; radialYLabel.appendChild(radYSpan);
|
||||
const radialYInput = document.createElement('input');
|
||||
radialYInput.type = 'range'; radialYInput.className = 'form-control-range';
|
||||
radialYInput.min = '0'; radialYInput.max = '100'; radialYInput.step = '5';
|
||||
radialYInput.value = this.data.gradientRadialY;
|
||||
radialYInput.addEventListener('input', () => {
|
||||
this.data.gradientRadialY = parseInt(radialYInput.value);
|
||||
radYSpan.textContent = radialYInput.value + '%';
|
||||
updatePreview();
|
||||
});
|
||||
radialYCol.appendChild(radialYLabel); radialYCol.appendChild(radialYInput);
|
||||
|
||||
gradRow2.appendChild(radialXCol);
|
||||
gradRow2.appendChild(radialYCol);
|
||||
|
||||
// -- Colour pickers --
|
||||
const color1Col = document.createElement('div'); color1Col.className = 'col-md-2 mb-2';
|
||||
const color1Label = document.createElement('label');
|
||||
color1Label.className = 'small font-weight-bold text-secondary mb-1 d-block';
|
||||
color1Label.textContent = 'Colour 1';
|
||||
const color1Input = document.createElement('input');
|
||||
color1Input.type = 'color'; color1Input.className = 'form-control form-control-sm p-1';
|
||||
color1Input.style.height = '34px';
|
||||
color1Input.value = this.data.gradientColor1 || '#000000';
|
||||
color1Input.addEventListener('input', () => {
|
||||
this.data.gradientColor1 = color1Input.value;
|
||||
updatePreview();
|
||||
});
|
||||
color1Col.appendChild(color1Label); color1Col.appendChild(color1Input);
|
||||
|
||||
const color2Col = document.createElement('div'); color2Col.className = 'col-md-2 mb-2';
|
||||
const color2Label = document.createElement('label');
|
||||
color2Label.className = 'small font-weight-bold text-secondary mb-1 d-block';
|
||||
color2Label.textContent = 'Colour 2';
|
||||
const color2Input = document.createElement('input');
|
||||
color2Input.type = 'color'; color2Input.className = 'form-control form-control-sm p-1';
|
||||
color2Input.style.height = '34px';
|
||||
color2Input.value = this.data.gradientColor2 || '#1e3a5f';
|
||||
color2Input.addEventListener('input', () => {
|
||||
this.data.gradientColor2 = color2Input.value;
|
||||
updatePreview();
|
||||
});
|
||||
color2Col.appendChild(color2Label); color2Col.appendChild(color2Input);
|
||||
|
||||
gradRow2.appendChild(color1Col);
|
||||
gradRow2.appendChild(color2Col);
|
||||
gradSection.appendChild(gradRow2);
|
||||
|
||||
// ── Advanced Settings (CSS Class, Element ID, Style, Attrs) ──────────────
|
||||
const advSection = document.createElement('div');
|
||||
advSection.style.cssText = 'background:#f8f9fc;border:1px solid #e3e6f0;border-radius:6px;padding:12px;margin-bottom:12px;';
|
||||
|
||||
const advHeader = document.createElement('div');
|
||||
advHeader.className = 'small font-weight-bold text-secondary mb-2 cursor-pointer d-flex align-items-center justify-content-between';
|
||||
advHeader.style.cursor = 'pointer';
|
||||
advHeader.innerHTML = '<span><i class="fas fa-cog mr-1"></i> Advanced Attributes (CSS Class, Element ID, Style, Custom Attributes)</span><i class="fas fa-chevron-up ml-2 toggle-icon" style="font-size:10px;"></i>';
|
||||
|
||||
const advBody = document.createElement('div');
|
||||
advBody.style.display = 'block'; // Expanded by default so fields are immediately visible
|
||||
|
||||
advHeader.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const isHidden = advBody.style.display === 'none';
|
||||
advBody.style.display = isHidden ? 'block' : 'none';
|
||||
const icon = advHeader.querySelector('.toggle-icon');
|
||||
if (icon) {
|
||||
icon.className = `fas fa-chevron-${isHidden ? 'up' : 'down'} ml-2 toggle-icon`;
|
||||
}
|
||||
});
|
||||
|
||||
advSection.appendChild(advHeader);
|
||||
advSection.appendChild(advBody);
|
||||
|
||||
const advRow1 = document.createElement('div');
|
||||
advRow1.className = 'form-row';
|
||||
const cssCol = document.createElement('div'); cssCol.className = 'col-md-6 mb-2';
|
||||
const idCol = document.createElement('div'); idCol.className = 'col-md-6 mb-2';
|
||||
|
||||
const cssLabel = document.createElement('label'); cssLabel.className = 'small font-weight-bold text-secondary mb-1'; cssLabel.textContent = 'CSS Class';
|
||||
this.cssClassInput = document.createElement('input'); this.cssClassInput.type = 'text'; this.cssClassInput.className = 'form-control form-control-sm'; this.cssClassInput.value = this.data.cssClass || '';
|
||||
cssCol.appendChild(cssLabel); cssCol.appendChild(this.cssClassInput);
|
||||
|
||||
const idLabel = document.createElement('label'); idLabel.className = 'small font-weight-bold text-secondary mb-1'; idLabel.textContent = 'Element ID';
|
||||
this.elementIdInput = document.createElement('input'); this.elementIdInput.type = 'text'; this.elementIdInput.className = 'form-control form-control-sm'; this.elementIdInput.value = this.data.elementId || '';
|
||||
idCol.appendChild(idLabel); idCol.appendChild(this.elementIdInput);
|
||||
|
||||
advRow1.appendChild(cssCol); advRow1.appendChild(idCol);
|
||||
advBody.appendChild(advRow1);
|
||||
|
||||
const advRow2 = document.createElement('div');
|
||||
advRow2.className = 'form-row';
|
||||
const styleCol = document.createElement('div'); styleCol.className = 'col-md-6 mb-2';
|
||||
const attrsCol = document.createElement('div'); attrsCol.className = 'col-md-6 mb-2';
|
||||
|
||||
const styleLabel = document.createElement('label'); styleLabel.className = 'small font-weight-bold text-secondary mb-1'; styleLabel.textContent = 'Custom Style';
|
||||
this.customStyleInput = document.createElement('input'); this.customStyleInput.type = 'text'; this.customStyleInput.className = 'form-control form-control-sm'; this.customStyleInput.value = this.data.customStyle || '';
|
||||
styleCol.appendChild(styleLabel); styleCol.appendChild(this.customStyleInput);
|
||||
|
||||
const attrsLabel = document.createElement('label'); attrsLabel.className = 'small font-weight-bold text-secondary mb-1'; attrsLabel.textContent = 'Custom Attributes';
|
||||
this.customAttrsInput = document.createElement('input'); this.customAttrsInput.type = 'text'; this.customAttrsInput.className = 'form-control form-control-sm'; this.customAttrsInput.value = this.data.customAttrs || '';
|
||||
attrsCol.appendChild(attrsLabel); attrsCol.appendChild(this.customAttrsInput);
|
||||
|
||||
advRow2.appendChild(styleCol); advRow2.appendChild(attrsCol);
|
||||
advBody.appendChild(advRow2);
|
||||
|
||||
|
||||
// Show/hide angle vs radial controls based on type
|
||||
const updateDirectionVisibility = () => {
|
||||
const t = gradTypeSelect.value;
|
||||
const isLinear = ['linear', 'linear_both', 'duo_blue', 'duo_purple', 'duo_sunset', 'duo_ocean', 'duo_forest', 'duo_rose', 'custom_color'].includes(t);
|
||||
const isRadial = ['radial', 'radial_both'].includes(t);
|
||||
const showColors = ['linear', 'linear_both', 'radial', 'radial_both', 'custom_color'].includes(t);
|
||||
angleCol.style.display = isLinear ? '' : 'none';
|
||||
radialXCol.style.display = isRadial ? '' : 'none';
|
||||
radialYCol.style.display = isRadial ? '' : 'none';
|
||||
color1Col.style.display = showColors ? '' : 'none';
|
||||
color2Col.style.display = showColors ? '' : 'none';
|
||||
};
|
||||
updateDirectionVisibility();
|
||||
|
||||
gradTypeSelect.addEventListener('change', () => {
|
||||
this.data.gradientType = gradTypeSelect.value;
|
||||
updateDirectionVisibility();
|
||||
updatePreview();
|
||||
});
|
||||
angleInput.addEventListener('input', updateWheel);
|
||||
|
||||
// ── Live Preview Box ─────────────────────────────────────────────────────
|
||||
const previewBox = document.createElement('div');
|
||||
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 = '<h4 class="font-weight-bold mb-1 text-white">' + title + '</h4>';
|
||||
if (sub) html += '<p class="small mb-2 text-light">' + sub + '</p>';
|
||||
if (btnT) html += '<span class="btn btn-sm btn-danger font-weight-bold px-3">' + btnT + '</span>';
|
||||
// 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 = `<h4 class="font-weight-bold mb-1" style="color: ${txtColor} !important;">${title}</h4>`;
|
||||
if (sub) html += `<p class="small mb-2" style="color: ${txtColor} !important;">${sub}</p>`;
|
||||
if (btnT) html += `<span class="btn btn-sm btn-danger font-weight-bold px-3">${btnT}</span>`;
|
||||
contentBox.innerHTML = html;
|
||||
};
|
||||
|
||||
// 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 = '<i class="fas fa-images"></i> 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 = '<i class="fas fa-folder-open"></i>';
|
||||
btn.addEventListener('click', () => {
|
||||
const onSelectCallback = (url, mediaObj, config) => {
|
||||
let selectedUrl = '';
|
||||
if (typeof url === 'string') {
|
||||
selectedUrl = url;
|
||||
} else if (url && url.url) {
|
||||
selectedUrl = url.url;
|
||||
} else if (mediaObj && mediaObj.url) {
|
||||
selectedUrl = mediaObj.url;
|
||||
}
|
||||
});
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = '<label class="small font-weight-bold text-secondary mb-1">Limit Posts (12 = 4 rows)</label>';
|
||||
limitDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Limit Posts</label>';
|
||||
const limitInput = document.createElement('input');
|
||||
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 = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-folder"></i> Category Filter (Multi-Choice = Tabs)</label>';
|
||||
catDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-folder"></i> Category Filter</label>';
|
||||
const catSelect = document.createElement('select');
|
||||
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 = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-tags"></i> Tag Filter (Multi-Choice = Tabs)</label>';
|
||||
tagDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-tags"></i> Tag Filter</label>';
|
||||
const tagSelect = document.createElement('select');
|
||||
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 = '<i class="fas fa-external-link-alt"></i> URL nút "Xem Thêm"';
|
||||
|
||||
const viewMoreInput = document.createElement('input');
|
||||
viewMoreInput.type = 'text';
|
||||
viewMoreInput.className = 'form-control form-control-sm';
|
||||
viewMoreInput.placeholder = 'e.g. /category/tin-tuc or https://sisvietnam.vn/...';
|
||||
viewMoreInput.value = this.data.viewMoreUrl || '';
|
||||
if (this.readOnly) viewMoreInput.disabled = true;
|
||||
viewMoreInput.addEventListener('input', (e) => this.data.viewMoreUrl = e.target.value.trim());
|
||||
this.viewMoreInput = viewMoreInput;
|
||||
|
||||
viewMoreDiv.appendChild(viewMoreLabel);
|
||||
viewMoreDiv.appendChild(viewMoreInput);
|
||||
viewMoreRow.appendChild(viewMoreDiv);
|
||||
formRow2.appendChild(viewMoreRow);
|
||||
|
||||
// Helper: count unique non-ALL selections across both selects
|
||||
const updateViewMoreVisibility = () => {
|
||||
const cats = (this.data.categoryFilter || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const tags = (this.data.tagFilter || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const isSingle = (cats.length === 1 && tags.length === 0) || (tags.length === 1 && cats.length === 0);
|
||||
viewMoreRow.style.display = isSingle ? '' : 'none';
|
||||
};
|
||||
updateViewMoreVisibility();
|
||||
|
||||
// 3. Location Filter Attribute
|
||||
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 = '<i class="fas fa-external-link-alt"></i> URL nút "Xem Thêm"';
|
||||
|
||||
const viewMoreUrlInput = document.createElement('input');
|
||||
viewMoreUrlInput.type = 'text';
|
||||
viewMoreUrlInput.className = 'form-control form-control-sm';
|
||||
viewMoreUrlInput.placeholder = 'e.g. /posts/category/tin-tuc or https://sisvietnam.vn/...';
|
||||
viewMoreUrlInput.value = this.data.viewMoreUrl || '';
|
||||
if (this.readOnly) viewMoreUrlInput.disabled = true;
|
||||
viewMoreUrlInput.addEventListener('input', (e) => this.data.viewMoreUrl = e.target.value.trim());
|
||||
this.viewMoreInput = viewMoreUrlInput;
|
||||
|
||||
viewMoreUrlCol.appendChild(viewMoreUrlLabel);
|
||||
viewMoreUrlCol.appendChild(viewMoreUrlInput);
|
||||
viewMoreUrlRow.appendChild(viewMoreUrlCol);
|
||||
this.wrapper.appendChild(viewMoreUrlRow);
|
||||
|
||||
// Wire the showViewMore checkbox to show/hide the URL row
|
||||
// Find the showViewMore checkbox after all toggles have been rendered
|
||||
const showViewMoreCheck = toggleRow.querySelector('input[id^="posts-toggle-showViewMore"]');
|
||||
if (showViewMoreCheck) {
|
||||
showViewMoreCheck.addEventListener('change', (e) => {
|
||||
this.data.showViewMore = e.target.checked;
|
||||
viewMoreUrlRow.style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
return this.wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Select2 on a <select multiple> element once it is attached to the DOM.
|
||||
* Editor.js inserts the wrapper returned by render() into the DOM *after* render() returns,
|
||||
* so we poll with requestAnimationFrame until the element is connected, then call Select2.
|
||||
*
|
||||
* @param {HTMLSelectElement} selectEl - The select element to enhance.
|
||||
* @param {string} placeholder - Placeholder text shown when nothing is selected.
|
||||
*/
|
||||
_scheduleSelect2(selectEl, placeholder) {
|
||||
const tryInit = () => {
|
||||
if (!selectEl.isConnected) {
|
||||
requestAnimationFrame(tryInit);
|
||||
return;
|
||||
}
|
||||
if (typeof $ === 'undefined' || typeof $.fn.select2 === 'undefined') return;
|
||||
|
||||
$(selectEl).select2({
|
||||
theme: 'bootstrap',
|
||||
placeholder: placeholder,
|
||||
allowClear: true,
|
||||
width: '100%'
|
||||
});
|
||||
|
||||
// Select2 swallows native 'change' events – re-fire so our existing listeners work
|
||||
$(selectEl).on('select2:select select2:unselect', function () {
|
||||
selectEl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(tryInit);
|
||||
}
|
||||
|
||||
save() {
|
||||
return {
|
||||
category: this.data.category || 'ALL',
|
||||
@@ -402,7 +499,7 @@ class SISPostsTool {
|
||||
statusFilter: this.data.statusFilter || 'PUBLISHED',
|
||||
displayStyle: this.data.displayStyle || 'grid',
|
||||
cols: parseInt(this.data.cols) || 3,
|
||||
limit: parseInt(this.data.limit) || 6,
|
||||
limit: parseInt(this.data.limit) || 12,
|
||||
orderBy: this.data.orderBy || 'createdDate_desc',
|
||||
locationFilter: this.data.locationFilter || '',
|
||||
postIds: this.data.postIds || '',
|
||||
@@ -410,9 +507,11 @@ class SISPostsTool {
|
||||
showExcerpt: !!this.data.showExcerpt,
|
||||
showDate: !!this.data.showDate,
|
||||
showLocation: !!this.data.showLocation,
|
||||
itemsPerPage: parseInt(this.data.itemsPerPage) || parseInt(this.data.limit) || 9,
|
||||
itemsPerPage: parseInt(this.data.itemsPerPage) || parseInt(this.data.limit) || 12,
|
||||
enablePagination: !!this.data.enablePagination,
|
||||
enableTabs: !!this.data.enableTabs,
|
||||
showViewMore: !!this.data.showViewMore,
|
||||
viewMoreUrl: this.viewMoreInput ? this.viewMoreInput.value.trim() : (this.data.viewMoreUrl || ''),
|
||||
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
|
||||
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || 'sis-posts-block'),
|
||||
globalStyle: this.globalStyleInput ? this.globalStyleInput.value.trim() : (this.data.globalStyle || ''),
|
||||
|
||||
@@ -9,21 +9,28 @@
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
constructor({ data, api, readOnly, block }) {
|
||||
this.api = api;
|
||||
this.blockAPI = block; // Store Block API for reliable index lookup
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
html: data.html || ''
|
||||
html: data.html || '',
|
||||
cssClass: data.cssClass || '',
|
||||
elementId: data.elementId || '',
|
||||
customStyle: data.customStyle || '',
|
||||
customAttrs: data.customAttrs || ''
|
||||
};
|
||||
this.editorId = 'tinymce-editor-' + Math.random().toString(36).substring(7);
|
||||
this.editorInstance = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'sis-tinymce-container d-flex flex-column h-100';
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'sis-tinymce-block-wrapper border rounded p-2 bg-white d-flex flex-column h-100 flex-fill';
|
||||
wrapper.className = 'sis-tinymce-block-wrapper border rounded p-2 bg-white d-flex flex-column flex-fill mb-2';
|
||||
wrapper.style.minHeight = '280px';
|
||||
wrapper.style.height = '100%';
|
||||
wrapper.style.flex = '1 1 auto';
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
@@ -34,10 +41,71 @@
|
||||
textarea.style.minHeight = '250px';
|
||||
wrapper.appendChild(textarea);
|
||||
this.textareaElement = textarea;
|
||||
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// ── Advanced Settings (CSS Class, Element ID, Style, Attrs) ──────────────
|
||||
const advSection = document.createElement('div');
|
||||
advSection.style.cssText = 'background:#f8f9fc;border:1px solid #e3e6f0;border-radius:6px;padding:12px;margin-bottom:12px;';
|
||||
|
||||
const advHeader = document.createElement('div');
|
||||
advHeader.className = 'small font-weight-bold text-secondary mb-2 cursor-pointer d-flex align-items-center justify-content-between';
|
||||
advHeader.style.cursor = 'pointer';
|
||||
advHeader.innerHTML = '<span><i class="fas fa-cog mr-1"></i> Advanced Attributes (CSS Class, Element ID, Style, Custom Attributes)</span><i class="fas fa-chevron-up ml-2 toggle-icon" style="font-size:10px;"></i>';
|
||||
|
||||
const advBody = document.createElement('div');
|
||||
advBody.style.display = 'block'; // Expanded by default so fields are immediately visible
|
||||
|
||||
advHeader.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const isHidden = advBody.style.display === 'none';
|
||||
advBody.style.display = isHidden ? 'block' : 'none';
|
||||
const icon = advHeader.querySelector('.toggle-icon');
|
||||
if (icon) {
|
||||
icon.className = `fas fa-chevron-${isHidden ? 'up' : 'down'} ml-2 toggle-icon`;
|
||||
}
|
||||
});
|
||||
|
||||
advSection.appendChild(advHeader);
|
||||
advSection.appendChild(advBody);
|
||||
|
||||
const advRow1 = document.createElement('div');
|
||||
advRow1.className = 'form-row';
|
||||
const cssCol = document.createElement('div'); cssCol.className = 'col-md-6 mb-2';
|
||||
const idCol = document.createElement('div'); idCol.className = 'col-md-6 mb-2';
|
||||
|
||||
const cssLabel = document.createElement('label'); cssLabel.className = 'small font-weight-bold text-secondary mb-1'; cssLabel.textContent = 'CSS Class';
|
||||
this.cssClassInput = document.createElement('input'); this.cssClassInput.type = 'text'; this.cssClassInput.className = 'form-control form-control-sm'; this.cssClassInput.value = this.data.cssClass || '';
|
||||
cssCol.appendChild(cssLabel); cssCol.appendChild(this.cssClassInput);
|
||||
|
||||
const idLabel = document.createElement('label'); idLabel.className = 'small font-weight-bold text-secondary mb-1'; idLabel.textContent = 'Element ID';
|
||||
this.elementIdInput = document.createElement('input'); this.elementIdInput.type = 'text'; this.elementIdInput.className = 'form-control form-control-sm'; this.elementIdInput.value = this.data.elementId || '';
|
||||
idCol.appendChild(idLabel); idCol.appendChild(this.elementIdInput);
|
||||
|
||||
advRow1.appendChild(cssCol); advRow1.appendChild(idCol);
|
||||
advBody.appendChild(advRow1);
|
||||
|
||||
const advRow2 = document.createElement('div');
|
||||
advRow2.className = 'form-row';
|
||||
const styleCol = document.createElement('div'); styleCol.className = 'col-md-6 mb-2';
|
||||
const attrsCol = document.createElement('div'); attrsCol.className = 'col-md-6 mb-2';
|
||||
|
||||
const styleLabel = document.createElement('label'); styleLabel.className = 'small font-weight-bold text-secondary mb-1'; styleLabel.textContent = 'Custom Style';
|
||||
this.customStyleInput = document.createElement('input'); this.customStyleInput.type = 'text'; this.customStyleInput.className = 'form-control form-control-sm'; this.customStyleInput.value = this.data.customStyle || '';
|
||||
styleCol.appendChild(styleLabel); styleCol.appendChild(this.customStyleInput);
|
||||
|
||||
const attrsLabel = document.createElement('label'); attrsLabel.className = 'small font-weight-bold text-secondary mb-1'; attrsLabel.textContent = 'Custom Attributes';
|
||||
this.customAttrsInput = document.createElement('input'); this.customAttrsInput.type = 'text'; this.customAttrsInput.className = 'form-control form-control-sm'; this.customAttrsInput.value = this.data.customAttrs || '';
|
||||
attrsCol.appendChild(attrsLabel); attrsCol.appendChild(this.customAttrsInput);
|
||||
|
||||
advRow2.appendChild(styleCol); advRow2.appendChild(attrsCol);
|
||||
advBody.appendChild(advRow2);
|
||||
|
||||
container.appendChild(advSection);
|
||||
|
||||
this._initTinyMCE();
|
||||
|
||||
return wrapper;
|
||||
return container;
|
||||
}
|
||||
|
||||
_initTinyMCE() {
|
||||
@@ -96,7 +164,34 @@
|
||||
menubar: false,
|
||||
readonly: !!self.readOnly,
|
||||
plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount',
|
||||
toolbar: 'undo redo | blocks | bold italic forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image | code removeformat',
|
||||
toolbar: 'undo redo | blocks | bold italic forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image sismedia | code removeformat',
|
||||
valid_elements: '*[*]',
|
||||
extended_valid_elements: '*[*]',
|
||||
valid_children: '+body[style|script|iframe],+div[style|script|iframe]',
|
||||
verify_html: false,
|
||||
allow_script_urls: true,
|
||||
allow_html_in_named_anchor: true,
|
||||
image_title: true,
|
||||
file_picker_types: 'image media file',
|
||||
file_picker_callback: function(callback, value, meta) {
|
||||
self._loadSISMediaPicker(function() {
|
||||
if (window.SISMediaPicker) {
|
||||
window.SISMediaPicker.open(function(url, mediaObj, config) {
|
||||
if (url) {
|
||||
var info = {
|
||||
alt: (config && config.alt) ? config.alt : '',
|
||||
title: (mediaObj && (mediaObj.originalFilename || mediaObj.name)) ? (mediaObj.originalFilename || mediaObj.name) : ''
|
||||
};
|
||||
if (config) {
|
||||
if (config.width) info.width = config.width;
|
||||
if (config.height) info.height = config.height;
|
||||
}
|
||||
callback(url, info);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
image_dimensions: true,
|
||||
object_resizing: 'img',
|
||||
content_style: 'body { font-family: Roboto, ui-sans-serif, sans-serif; font-size: 14px; color: #333; } img { max-width: 100%; height: auto; cursor: pointer; display: inline-block; }',
|
||||
@@ -114,6 +209,74 @@
|
||||
},
|
||||
setup: function(editor) {
|
||||
self.editorInstance = editor;
|
||||
|
||||
// Intercept mceCodeEditor command to open Monaco Editor instead of default TinyMCE modal
|
||||
editor.on('BeforeExecCommand', function(e) {
|
||||
if (e && e.command === 'mceCodeEditor') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
self._openMonacoCodeModal(editor);
|
||||
}
|
||||
});
|
||||
|
||||
editor.on('init', function() {
|
||||
editor.addCommand('mceCodeEditor', function() {
|
||||
self._openMonacoCodeModal(editor);
|
||||
});
|
||||
|
||||
editor.ui.registry.addButton('code', {
|
||||
icon: 'sourcecode',
|
||||
tooltip: 'Xem / Sửa Mã Nguồn (Monaco Editor)',
|
||||
onAction: function() {
|
||||
self._openMonacoCodeModal(editor);
|
||||
}
|
||||
});
|
||||
|
||||
editor.ui.registry.addMenuItem('code', {
|
||||
icon: 'sourcecode',
|
||||
text: 'Mã nguồn HTML (Monaco Editor)',
|
||||
onAction: function() {
|
||||
self._openMonacoCodeModal(editor);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Register SIS Media Library toolbar button
|
||||
editor.ui.registry.addButton('sismedia', {
|
||||
icon: 'image',
|
||||
tooltip: 'Chèn media từ Thư viện SIS (Media Library)',
|
||||
onAction: function() {
|
||||
self._loadSISMediaPicker(function() {
|
||||
if (window.SISMediaPicker) {
|
||||
window.SISMediaPicker.open(function(url, mediaObj, config) {
|
||||
if (!url) return;
|
||||
var fileType = (mediaObj && mediaObj.fileType) ? String(mediaObj.fileType).toUpperCase() : '';
|
||||
var ext = url.split('?')[0].split('.').pop().toLowerCase();
|
||||
var isVideo = fileType === 'VIDEO' || ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'avi', 'mkv'].indexOf(ext) !== -1;
|
||||
var isAudio = fileType === 'AUDIO' || ['mp3', 'wav', 'ogg', 'aac', 'm4a', 'flac'].indexOf(ext) !== -1;
|
||||
|
||||
var attrStr = '';
|
||||
if (config) {
|
||||
if (config.alt) attrStr += ' alt="' + editor.dom.encode(config.alt) + '"';
|
||||
if (config.id || config.elementId) attrStr += ' id="' + editor.dom.encode(config.id || config.elementId) + '"';
|
||||
if (config.cssClass || config.class) attrStr += ' class="' + editor.dom.encode(config.cssClass || config.class) + '"';
|
||||
if (config.style) attrStr += ' style="' + editor.dom.encode(config.style) + '"';
|
||||
if (config.customAttributes) attrStr += ' ' + config.customAttributes;
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
editor.insertContent('<video controls width="100%" src="' + url + '"' + attrStr + '></video>');
|
||||
} else if (isAudio) {
|
||||
editor.insertContent('<audio controls src="' + url + '"' + attrStr + '></audio>');
|
||||
} else {
|
||||
editor.insertContent('<img src="' + url + '"' + attrStr + ' />');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
editor.on('init', function() {
|
||||
const container = editor.getContainer();
|
||||
if (container) {
|
||||
@@ -140,6 +303,63 @@
|
||||
editor.on('change keyup undo redo', function() {
|
||||
self.data.html = editor.getContent();
|
||||
});
|
||||
|
||||
// Use Editor.js API to set active block and move toolbar
|
||||
const activateBlock = function() {
|
||||
if (!self.api || !self.api.blocks || !self.textareaElement) return;
|
||||
|
||||
try {
|
||||
const container = self.textareaElement.closest('.ce-block');
|
||||
if (!container) return;
|
||||
|
||||
let myIndex = -1;
|
||||
|
||||
// Best way: use public getBlockByElement to trace from the actual DOM element
|
||||
if (typeof self.api.blocks.getBlockByElement === 'function') {
|
||||
const blockApiObj = self.api.blocks.getBlockByElement(self.textareaElement);
|
||||
if (blockApiObj && blockApiObj.id) {
|
||||
myIndex = self.api.blocks.getBlockIndex(blockApiObj.id);
|
||||
}
|
||||
} else if (self.blockAPI && self.blockAPI.id) {
|
||||
// Fallback to constructor block API
|
||||
myIndex = self.api.blocks.getBlockIndex(self.blockAPI.id);
|
||||
}
|
||||
|
||||
if (myIndex !== -1) {
|
||||
const currentIdx = self.api.blocks.getCurrentBlockIndex();
|
||||
if (currentIdx !== myIndex) {
|
||||
// Temporarily disable only text inputs (not textarea) so Editor.js doesn't steal focus
|
||||
const inputs = container.querySelectorAll('.sis-tinymce-container input');
|
||||
const disabledStates = [];
|
||||
inputs.forEach(input => {
|
||||
disabledStates.push(input.disabled);
|
||||
input.disabled = true;
|
||||
});
|
||||
|
||||
// Set active block (moves the toolbar)
|
||||
self.api.caret.setToBlock(myIndex, 'default');
|
||||
|
||||
// Restore inputs
|
||||
inputs.forEach((input, idx) => {
|
||||
input.disabled = disabledStates[idx];
|
||||
});
|
||||
|
||||
// Refocus TinyMCE
|
||||
setTimeout(() => {
|
||||
if (editor && typeof editor.focus === 'function') {
|
||||
editor.focus();
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("SIS TinyMCE: Failed to set active block", e);
|
||||
}
|
||||
};
|
||||
|
||||
editor.on('focusin focus click keyup', function() {
|
||||
activateBlock();
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -147,12 +367,264 @@
|
||||
checkAndInit();
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
if (this.editorInstance) {
|
||||
this.data.html = this.editorInstance.getContent();
|
||||
_loadSISMediaPicker(callback) {
|
||||
if (window.SISMediaPicker) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
window.SISMediaPickerLoading = window.SISMediaPickerLoading || {
|
||||
loaded: false,
|
||||
callbacks: []
|
||||
};
|
||||
if (window.SISMediaPickerLoading.loaded) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
window.SISMediaPickerLoading.callbacks.push(callback);
|
||||
if (window.SISMediaPickerLoading.callbacks.length === 1) {
|
||||
const script = document.createElement('script');
|
||||
script.src = '/js/manage/sis-media-picker.js';
|
||||
script.onload = () => {
|
||||
window.SISMediaPickerLoading.loaded = true;
|
||||
while (window.SISMediaPickerLoading.callbacks.length > 0) {
|
||||
const cb = window.SISMediaPickerLoading.callbacks.shift();
|
||||
try { cb(); } catch (e) { console.error(e); }
|
||||
}
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.error('[SIS TinyMCE Tool] Failed to load /js/manage/sis-media-picker.js');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}
|
||||
|
||||
_loadMonacoEditor(callback) {
|
||||
if (window.monaco && window.monaco.editor) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
window.SISMonacoLoading = window.SISMonacoLoading || {
|
||||
loaded: false,
|
||||
callbacks: []
|
||||
};
|
||||
if (window.SISMonacoLoading.loaded && window.monaco && window.monaco.editor) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
window.SISMonacoLoading.callbacks.push(callback);
|
||||
if (window.SISMonacoLoading.callbacks.length === 1) {
|
||||
const loaderScript = document.createElement('script');
|
||||
loaderScript.src = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js';
|
||||
loaderScript.onload = () => {
|
||||
if (window.require) {
|
||||
window.require.config({
|
||||
paths: {
|
||||
'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs'
|
||||
}
|
||||
});
|
||||
window.require(['vs/editor/editor.main'], () => {
|
||||
window.SISMonacoLoading.loaded = true;
|
||||
while (window.SISMonacoLoading.callbacks.length > 0) {
|
||||
const cb = window.SISMonacoLoading.callbacks.shift();
|
||||
try { cb(); } catch (e) { console.error(e); }
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('[Monaco Editor] Loader script loaded but window.require is undefined.');
|
||||
}
|
||||
};
|
||||
loaderScript.onerror = () => {
|
||||
console.error('[Monaco Editor] Failed to load Monaco Editor loader script.');
|
||||
};
|
||||
document.head.appendChild(loaderScript);
|
||||
}
|
||||
}
|
||||
|
||||
_openMonacoCodeModal(editor) {
|
||||
const self = this;
|
||||
const currentHtml = editor.getContent({ format: 'raw' });
|
||||
|
||||
// Create backdrop
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'sis-monaco-modal-backdrop';
|
||||
backdrop.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(15,23,42,0.75);backdrop-filter:blur(4px);z-index:999999;display:flex;align-items:center;justify-content:center;padding:20px;';
|
||||
|
||||
// Create modal window
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'sis-monaco-modal-container';
|
||||
modal.style.cssText = 'background:#1e1e1e;color:#d4d4d4;width:92vw;max-width:1250px;height:88vh;border-radius:10px;box-shadow:0 25px 50px -12px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.1);display:flex;flex-direction:column;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'padding:12px 20px;background:#252526;border-bottom:1px solid #333333;display:flex;align-items:center;justify-content:space-between;user-select:none;';
|
||||
header.innerHTML = `
|
||||
<div style="font-weight:600;font-size:15px;color:#ffffff;display:flex;align-items:center;gap:10px;">
|
||||
<i class="fas fa-code" style="color:#007acc;font-size:16px;"></i>
|
||||
<span>Mã nguồn HTML (Monaco Code Editor)</span>
|
||||
</div>
|
||||
<button type="button" class="monaco-close-x-btn" title="Đóng" style="background:none;border:none;color:#aaaaaa;font-size:22px;cursor:pointer;line-height:1;padding:0 4px;transition:color 0.2s;">×</button>
|
||||
`;
|
||||
|
||||
// Body (Monaco host)
|
||||
const body = document.createElement('div');
|
||||
body.style.cssText = 'flex:1 1 auto;width:100%;height:100%;position:relative;background:#1e1e1e;overflow:hidden;';
|
||||
|
||||
const loadingSpinner = document.createElement('div');
|
||||
loadingSpinner.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#9cdcfe;font-size:15px;display:flex;align-items:center;gap:10px;z-index:2;';
|
||||
loadingSpinner.innerHTML = '<i class="fas fa-spinner fa-spin" style="font-size:18px;"></i> Đang tải Monaco Editor...';
|
||||
body.appendChild(loadingSpinner);
|
||||
|
||||
// Footer
|
||||
const footer = document.createElement('div');
|
||||
footer.style.cssText = 'padding:10px 20px;background:#252526;border-top:1px solid #333333;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;';
|
||||
footer.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<button type="button" class="btn btn-sm monaco-format-btn" style="background:#2d2d2d;color:#cccccc;border:1px solid #454545;border-radius:4px;padding:4px 10px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;"><i class="fas fa-magic" style="color:#ce9178;"></i> Định dạng mã (Format)</button>
|
||||
<button type="button" class="btn btn-sm monaco-wrap-btn" style="background:#2d2d2d;color:#cccccc;border:1px solid #454545;border-radius:4px;padding:4px 10px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;"><i class="fas fa-align-left" style="color:#9cdcfe;"></i> Word Wrap: On</button>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<button type="button" class="btn btn-sm monaco-cancel-btn" style="background:#3a3d41;color:#ffffff;border:none;border-radius:4px;padding:6px 14px;font-size:13px;cursor:pointer;">Hủy bỏ</button>
|
||||
<button type="button" class="btn btn-sm monaco-save-btn" style="background:#0e639c;color:#ffffff;border:none;border-radius:4px;padding:6px 16px;font-size:13px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px;"><i class="fas fa-check"></i> Lưu thay đổi (Ctrl+S)</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(body);
|
||||
modal.appendChild(footer);
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
let monacoInstance = null;
|
||||
let isWordWrap = true;
|
||||
|
||||
const closeModal = () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
if (monacoInstance) {
|
||||
try { monacoInstance.dispose(); } catch (e) {}
|
||||
monacoInstance = null;
|
||||
}
|
||||
self.activeMonacoInstance = null;
|
||||
backdrop.remove();
|
||||
};
|
||||
|
||||
const saveChanges = () => {
|
||||
if (monacoInstance) {
|
||||
const updatedCode = monacoInstance.getValue();
|
||||
self.data.html = updatedCode;
|
||||
if (self.textareaElement) {
|
||||
self.textareaElement.value = updatedCode;
|
||||
}
|
||||
if (editor) {
|
||||
try {
|
||||
editor.setContent(updatedCode, { format: 'raw' });
|
||||
editor.save();
|
||||
editor.dispatch('change');
|
||||
} catch (err) {
|
||||
console.warn('[SIS TinyMCE] Error setting TinyMCE content from Monaco:', err);
|
||||
}
|
||||
}
|
||||
if (self.api && self.api.events) {
|
||||
try {
|
||||
self.api.events.emit('change');
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
||||
// Bind Header Close button
|
||||
const closeXBtn = header.querySelector('.monaco-close-x-btn');
|
||||
closeXBtn.addEventListener('click', closeModal);
|
||||
closeXBtn.addEventListener('mouseenter', () => { closeXBtn.style.color = '#ffffff'; });
|
||||
closeXBtn.addEventListener('mouseleave', () => { closeXBtn.style.color = '#aaaaaa'; });
|
||||
|
||||
// Bind Footer Buttons
|
||||
footer.querySelector('.monaco-cancel-btn').addEventListener('click', closeModal);
|
||||
footer.querySelector('.monaco-save-btn').addEventListener('click', saveChanges);
|
||||
|
||||
const wrapBtn = footer.querySelector('.monaco-wrap-btn');
|
||||
|
||||
// Load Monaco
|
||||
self._loadMonacoEditor(() => {
|
||||
if (!document.body.contains(backdrop)) return; // modal was closed before loaded
|
||||
|
||||
if (loadingSpinner.parentNode) {
|
||||
loadingSpinner.parentNode.removeChild(loadingSpinner);
|
||||
}
|
||||
|
||||
monacoInstance = monaco.editor.create(body, {
|
||||
value: currentHtml,
|
||||
language: 'html',
|
||||
theme: 'vs-dark',
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: true },
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
renderWhitespace: 'selection',
|
||||
tabSize: 2,
|
||||
folding: true,
|
||||
formatOnPaste: true
|
||||
});
|
||||
|
||||
self.activeMonacoInstance = monacoInstance;
|
||||
|
||||
// Ctrl+S / Cmd+S save shortcut inside Monaco
|
||||
monacoInstance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
saveChanges();
|
||||
});
|
||||
|
||||
// Format document action button
|
||||
const formatBtn = footer.querySelector('.monaco-format-btn');
|
||||
formatBtn.addEventListener('click', () => {
|
||||
if (monacoInstance) {
|
||||
monacoInstance.getAction('editor.action.formatDocument').run();
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle Word Wrap
|
||||
wrapBtn.addEventListener('click', () => {
|
||||
if (!monacoInstance) return;
|
||||
isWordWrap = !isWordWrap;
|
||||
monacoInstance.updateOptions({ wordWrap: isWordWrap ? 'on' : 'off' });
|
||||
wrapBtn.innerHTML = `<i class="fas fa-align-left" style="color:#9cdcfe;"></i> Word Wrap: ${isWordWrap ? 'On' : 'Off'}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
if (this.activeMonacoInstance) {
|
||||
try {
|
||||
this.data.html = this.activeMonacoInstance.getValue();
|
||||
} catch (e) {}
|
||||
} else if (this.editorInstance) {
|
||||
try {
|
||||
this.editorInstance.save();
|
||||
const content = this.editorInstance.getContent({ format: 'raw' });
|
||||
if (content !== undefined && content !== null) {
|
||||
this.data.html = content;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[SIS TinyMCE Tool] Failed to get editor content:', e);
|
||||
}
|
||||
} else if (this.textareaElement) {
|
||||
this.data.html = this.textareaElement.value;
|
||||
}
|
||||
|
||||
return {
|
||||
html: this.data.html
|
||||
html: this.data.html || '',
|
||||
cssClass: this.cssClassInput ? this.cssClassInput.value.trim() : (this.data.cssClass || ''),
|
||||
elementId: this.elementIdInput ? this.elementIdInput.value.trim() : (this.data.elementId || ''),
|
||||
customStyle: this.customStyleInput ? this.customStyleInput.value.trim() : (this.data.customStyle || ''),
|
||||
customAttrs: this.customAttrsInput ? this.customAttrsInput.value.trim() : (this.data.customAttrs || '')
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -52,12 +52,23 @@
|
||||
|
||||
/* ── initModal ─────────────────────────────────────── */
|
||||
initModal: function () {
|
||||
if (!document.getElementById('sisMediaPickerStyle')) {
|
||||
var styleEl = document.createElement('style');
|
||||
styleEl.id = 'sisMediaPickerStyle';
|
||||
styleEl.innerHTML = '#sisMediaLibraryModal { z-index: 999999 !important; } .sis-media-backdrop { z-index: 999990 !important; }';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
var modalEl = document.getElementById(this.modalId);
|
||||
if (modalEl) return modalEl;
|
||||
if (modalEl) {
|
||||
modalEl.style.setProperty('z-index', '999999', 'important');
|
||||
return modalEl;
|
||||
}
|
||||
|
||||
modalEl = document.createElement('div');
|
||||
modalEl.id = this.modalId;
|
||||
modalEl.className = 'modal fade';
|
||||
modalEl.style.setProperty('z-index', '999999', 'important');
|
||||
modalEl.setAttribute('tabindex', '-1');
|
||||
modalEl.setAttribute('role', 'dialog');
|
||||
modalEl.setAttribute('aria-hidden', 'true');
|
||||
@@ -357,17 +368,35 @@
|
||||
initialConfig = arg1.config || arg1.currentConfig || arg1.initialConfig || arg1;
|
||||
if (typeof arg2 === 'function') {
|
||||
callback = arg2;
|
||||
} else if (typeof arg1.onSelect === 'function') {
|
||||
callback = arg1.onSelect;
|
||||
} else if (typeof arg1.callback === 'function') {
|
||||
callback = arg1.callback;
|
||||
}
|
||||
}
|
||||
|
||||
this.callback = callback;
|
||||
var modalEl = this.initModal();
|
||||
modalEl.style.setProperty('z-index', '999999', 'important');
|
||||
|
||||
if (typeof $ !== 'undefined' && $.fn && $.fn.modal) {
|
||||
$(modalEl).off('show.bs.modal.sis z-index.sis').on('show.bs.modal.sis', function () {
|
||||
modalEl.style.setProperty('z-index', '999999', 'important');
|
||||
setTimeout(function() {
|
||||
modalEl.style.setProperty('z-index', '999999', 'important');
|
||||
var backdrops = document.querySelectorAll('.modal-backdrop');
|
||||
if (backdrops.length > 0) {
|
||||
var lastBackdrop = backdrops[backdrops.length - 1];
|
||||
lastBackdrop.classList.add('sis-media-backdrop');
|
||||
lastBackdrop.style.setProperty('z-index', '999990', 'important');
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
$(modalEl).modal('show');
|
||||
} else {
|
||||
modalEl.style.display = 'block';
|
||||
modalEl.classList.add('show');
|
||||
modalEl.style.setProperty('z-index', '999999', 'important');
|
||||
}
|
||||
|
||||
// Reset search input
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
<!-- 🟢 1. SWIPER.JS CSS (Đã thêm) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css" />
|
||||
|
||||
<!-- FontAwesome & Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
|
||||
<!-- Custom CSS (Highest Priority) -->
|
||||
<link rel="stylesheet" th:href="@{/css/custom.css}" />
|
||||
</head>
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
|
||||
<!-- Project-wide CSS Variables & Custom Styles -->
|
||||
<link rel="stylesheet" th:href="@{/css/custom.css}">
|
||||
<!-- Select2 -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2-bootstrap-theme/0.1.0-beta.10/select2-bootstrap.min.css">
|
||||
<!-- Admin Head Hook -->
|
||||
<th:block th:utext="${hookManager.doActionAndReturn('admin_head')}"></th:block>
|
||||
</head>
|
||||
@@ -407,6 +410,13 @@
|
||||
|
||||
<!-- Bootstrap core JavaScript-->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
|
||||
<script th:inline="javascript">
|
||||
$(document).ajaxError(function(event, jqXHR, settings, thrownError) {
|
||||
if (jqXHR.status === 401 || jqXHR.status === 403) {
|
||||
window.location.href = /*[[@{/manage/login?expired}]]*/ '/manage/login?expired';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.0/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Core plugin JavaScript-->
|
||||
@@ -415,6 +425,9 @@
|
||||
<!-- Custom scripts for all pages-->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/startbootstrap-sb-admin-2/4.1.4/js/sb-admin-2.min.js"></script>
|
||||
|
||||
<!-- Select2 JS -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js"></script>
|
||||
|
||||
<!-- Page-specific scripts injected by child templates -->
|
||||
<section layout:fragment="scripts"></section>
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
<div th:if="${param.logout}" class="alert alert-info">
|
||||
<span>Bạn đã đăng xuất thành công / You have been logged out.</span>
|
||||
</div>
|
||||
<div th:if="${param.expired}" class="alert alert-warning">
|
||||
<span>Phiên làm việc đã hết hạn. Vui lòng đăng nhập lại. / Session expired. Please log in again.</span>
|
||||
</div>
|
||||
|
||||
<form th:action="@{/manage/login}" class="form fv-plugins-bootstrap fv-plugins-framework" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
|
||||
@@ -226,6 +226,9 @@
|
||||
<i class="fab fa-css3-alt"></i> Custom CSS (Page-Specific Styles)
|
||||
</label>
|
||||
<div>
|
||||
<button type="button" class="btn btn-outline-info btn-sm px-2 py-0 mr-1" id="btnCompileCssNestingPage" title="Auto-flatten CSS nesting rules to standard flat CSS" style="font-size: 11px;">
|
||||
<i class="fas fa-magic"></i> Auto-Flatten Nesting
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm px-2 py-0 mr-1" id="btnGenMediaQueryPage" title="Auto-generate Media Queries for Mobile, Tablet & Desktop" style="font-size: 11px;">
|
||||
<i class="fas fa-mobile-alt"></i> Auto-Generate Media Queries
|
||||
</button>
|
||||
@@ -460,12 +463,14 @@
|
||||
<script th:src="@{/js/manage/editor-plugins/nav.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/hero-banner.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/text-styling.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/tiny-mce.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/tiny-mce.js(v=4)}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/posts.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/tab-plugin.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/custom-image.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/cms_plugin.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/youtube-plugin.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/googleForm.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/cards.js}"></script>
|
||||
|
||||
<!-- SIS Standalone Media Picker Library -->
|
||||
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
|
||||
@@ -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;
|
||||
|
||||
@@ -214,6 +214,9 @@
|
||||
<i class="fab fa-css3-alt"></i> Custom CSS (Post-Specific Styles)
|
||||
</label>
|
||||
<div>
|
||||
<button type="button" class="btn btn-outline-info btn-sm px-2 py-0 mr-1" id="btnCompileCssNestingPost" title="Auto-flatten CSS nesting rules to standard flat CSS" style="font-size: 11px;">
|
||||
<i class="fas fa-magic"></i> Auto-Flatten Nesting
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm px-2 py-0 mr-1" id="btnGenMediaQueryPost" title="Auto-generate Media Queries for Mobile, Tablet & Desktop" style="font-size: 11px;">
|
||||
<i class="fas fa-mobile-alt"></i> Auto-Generate Media Queries
|
||||
</button>
|
||||
@@ -922,6 +925,102 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
// CSS Nesting Auto-Flattener Helper
|
||||
function flattenCssNesting(css) {
|
||||
if (!css || typeof css !== 'string' || css.indexOf('{') === -1) return css;
|
||||
function processRules(cssText, parentSelector) {
|
||||
var result = '';
|
||||
var currentProps = '';
|
||||
var i = 0;
|
||||
while (i < cssText.length) {
|
||||
var char = cssText[i];
|
||||
if (char === '{') {
|
||||
var lastTerminator = Math.max(currentProps.lastIndexOf(';'), currentProps.lastIndexOf('}'));
|
||||
var rawSelector = (lastTerminator >= 0 ? currentProps.substring(lastTerminator + 1) : currentProps).trim();
|
||||
var propsBefore = (lastTerminator >= 0 ? currentProps.substring(0, lastTerminator + 1) : '').trim();
|
||||
var depth = 1;
|
||||
var start = i + 1;
|
||||
i++;
|
||||
while (i < cssText.length && depth > 0) {
|
||||
if (cssText[i] === '{') depth++;
|
||||
else if (cssText[i] === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
var blockContent = cssText.substring(start, i - 1);
|
||||
if (rawSelector.indexOf('@') === 0) {
|
||||
var innerFlat = processRules(blockContent, parentSelector);
|
||||
result += (propsBefore ? propsBefore + '\n' : '') + rawSelector + ' {\n' + innerFlat + '\n}\n';
|
||||
} else {
|
||||
var fullSelector = rawSelector;
|
||||
if (parentSelector) {
|
||||
var parents = parentSelector.split(',');
|
||||
var children = rawSelector.split(',');
|
||||
var combined = [];
|
||||
parents.forEach(function(p) {
|
||||
children.forEach(function(c) {
|
||||
p = p.trim();
|
||||
c = c.trim();
|
||||
if (c.indexOf('&') === 0) {
|
||||
combined.push(c.replace(/^&/, p));
|
||||
} else {
|
||||
combined.push(p + ' ' + c);
|
||||
}
|
||||
});
|
||||
});
|
||||
fullSelector = combined.join(', ');
|
||||
}
|
||||
var innerFlat = processRules(blockContent, fullSelector);
|
||||
result += (propsBefore ? propsBefore + '\n' : '') + innerFlat;
|
||||
}
|
||||
currentProps = '';
|
||||
continue;
|
||||
} else {
|
||||
currentProps += char;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (currentProps.trim() && parentSelector) {
|
||||
result = parentSelector + ' {\n ' + currentProps.trim().replace(/;\s*/g, ';\n ') + '\n}\n' + result;
|
||||
} else if (currentProps.trim()) {
|
||||
result += currentProps;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
return processRules(css, '').trim();
|
||||
} catch (e) {
|
||||
console.warn('[SIS CSS Flatten Error]', e);
|
||||
return css;
|
||||
}
|
||||
}
|
||||
|
||||
var btnCompileCss = document.getElementById('btnCompileCssNestingPost');
|
||||
if (btnCompileCss) {
|
||||
btnCompileCss.addEventListener('click', function () {
|
||||
var textarea = document.getElementById('postCustomCss');
|
||||
if (textarea && textarea.value) {
|
||||
textarea.value = flattenCssNesting(textarea.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var btnGenCssPost = document.getElementById('btnGenMediaQueryPost');
|
||||
if (btnGenCssPost) {
|
||||
btnGenCssPost.addEventListener('click', function () {
|
||||
insertResponsiveMediaQueries(document.getElementById('postCustomCss'));
|
||||
});
|
||||
}
|
||||
|
||||
var postForm = document.getElementById('postForm');
|
||||
if (postForm) {
|
||||
postForm.addEventListener('submit', function () {
|
||||
var textarea = document.getElementById('postCustomCss');
|
||||
if (textarea && textarea.value) {
|
||||
textarea.value = flattenCssNesting(textarea.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
enableTabIndentation(document.getElementById('postCustomCss'));
|
||||
enableTabIndentation(document.getElementById('postCustomJs'));
|
||||
});
|
||||
|
||||
@@ -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})}"
|
||||
>
|
||||
<head>
|
||||
<title th:text="${page.title}">Bệnh Viện S.I.S Cần Thơ</title>
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
<!-- Load lightweight Bootstrap Grid CSS to support grid/flex block layouts without overriding theme styles -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap-grid.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
|
||||
<!-- Customizer CSS overrides -->
|
||||
<style th:inline="css">
|
||||
@@ -38,7 +40,10 @@
|
||||
>
|
||||
<!-- 1. HTML Snippet / CMS Plugin Block -->
|
||||
<th:block th:if="${block['type'] == 'snippet' or block['type'] == 'cmsPlugin'}">
|
||||
<div th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
|
||||
<div th:classappend="${(block['cssClass'] != null ? block['cssClass'] + ' ' : '') + (block.data != null and block.data['cssClass'] != null ? block.data['cssClass'] : '')}"
|
||||
th:id="${block['elementId'] != null and !#strings.isEmpty(block['elementId']) ? block['elementId'] : (block.data != null and block.data['elementId'] != null ? block.data['elementId'] : '')}"
|
||||
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] + ';' : '') + (block.data != null and block.data['customStyle'] != null ? block.data['customStyle'] + ';' : '') + (block['alignment'] != null ? ' text-align: ' + block['alignment'] + ';' : '')}"
|
||||
th:attr="data-custom-attrs=${block['customAttrs'] != null ? block['customAttrs'] : (block.data != null and block.data['customAttrs'] != null ? block.data['customAttrs'] : '')}">
|
||||
<th:block th:if="${block['type'] == 'snippet'}">
|
||||
<div th:utext="${block.data['htmlContent']}" th:remove="tag"></div>
|
||||
</th:block>
|
||||
@@ -133,9 +138,14 @@
|
||||
|
||||
<!-- TinyMCE Rich Editor Block -->
|
||||
<th:block th:if="${block['type'] == 'tinymce'}">
|
||||
<div th:utext="${block.data['html']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></div>
|
||||
<div th:utext="${block.data['html']}"
|
||||
th:class="${(block.data['cssClass'] != null ? block.data['cssClass'] : '') + (block['cssClass'] != null ? ' ' + block['cssClass'] : '')}"
|
||||
th:id="${block.data['elementId'] != null and !block.data['elementId'].isEmpty() ? block.data['elementId'] : block['elementId']}"
|
||||
th:style="${(block.data['customStyle'] != null ? block.data['customStyle'] + ';' : '') + (block['customStyle'] != null ? block['customStyle'] + ';' : '') + (block.data['alignment'] != null ? 'text-align: ' + block.data['alignment'] + ';' : '')}"
|
||||
th:attr="data-custom-attrs=${block.data['customAttrs'] != null and !block.data['customAttrs'].isEmpty() ? block.data['customAttrs'] : block['customAttrs']}"></div>
|
||||
</th:block>
|
||||
|
||||
|
||||
<!-- 10. Accordion Block -->
|
||||
<th:block th:if="${block['type'] == 'accordion'}">
|
||||
<details class="sis-accordion-item mb-3" th:open="${block.data['isOpen'] == true}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
|
||||
@@ -195,9 +205,9 @@
|
||||
<!-- 12. Hero Banner Block -->
|
||||
<th:block th:if="${block['type'] == 'hero'}">
|
||||
<div
|
||||
class="sis-hero-banner text-white py-5 px-3 rounded shadow-sm text-center"
|
||||
th:classappend="${block['cssClass']}"
|
||||
th:id="${block['elementId'] != null ? block['elementId'] : null}"
|
||||
class="sis-hero-banner text-white py-5 px-3 rounded shadow-sm"
|
||||
th:classappend="${(block.data != null and (block.data['stretched'] == true or block.data['stretched'] == 'true') or block['stretched'] == true ? 'sis-fullwidth-breakout ' : '') + (block.data != null and block.data['textAlign'] == 'left' ? 'text-left ' : (block.data != null and block.data['textAlign'] == 'right' ? 'text-right ' : 'text-center ')) + (block.data != null and block.data['cssClass'] != null ? block.data['cssClass'] : (block['cssClass'] != null ? block['cssClass'] : ''))}"
|
||||
th:id="${(block.data != null and block.data['elementId'] != null and !#strings.isEmpty(block.data['elementId'])) ? block.data['elementId'] : (block['elementId'] != null ? block['elementId'] : null)}"
|
||||
style="
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -208,22 +218,75 @@
|
||||
background-position: center;
|
||||
background-color: #002554;
|
||||
"
|
||||
th:styleappend="${'min-height: ' + (block.data['height'] != null and !#strings.isEmpty(block.data['height']) ? block.data['height'] : '350px') + ';' + ((block.data['bgImage'] != null and !#strings.isEmpty(block.data['bgImage'])) ? 'background-image: url(' + block.data['bgImage'] + ');' : 'background: linear-gradient(135deg, #002554 0%, #881C1C 100%);') + (block['customStyle'] != null ? '; ' + block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] : '')}"
|
||||
th:attr="data-custom-attrs=${block['customAttrs']}"
|
||||
th:styleappend="${'min-height: ' + (block.data != null and block.data['height'] != null and !#strings.isEmpty(block.data['height']) ? block.data['height'] : '350px') + ';' + (block.data != null and block.data['bgImage'] != null and !#strings.isEmpty(block.data['bgImage']) ? 'background-image: url(' + block.data['bgImage'] + ');' : 'background: linear-gradient(135deg, #002554 0%, #881C1C 100%);') + (block.data != null and block.data['textAlign'] != null ? '; text-align: ' + block.data['textAlign'] : '') + (block.data != null and block.data['customStyle'] != null ? '; ' + block.data['customStyle'] : (block['customStyle'] != null ? '; ' + block['customStyle'] : '')) + (block['alignment'] != null ? '; text-align: ' + block['alignment'] : '')}"
|
||||
th:attr="data-custom-attrs=${(block.data != null and block.data['customAttrs'] != null) ? block.data['customAttrs'] : block['customAttrs']}"
|
||||
>
|
||||
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.4); z-index: 1"></div>
|
||||
<div class="container" style="position: relative; z-index: 2">
|
||||
<div class="sis-hero-overlay"
|
||||
style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 1;"
|
||||
th:attr="data-type=${block.data['gradientType'] != null ? block.data['gradientType'] : 'dark_bottom'},
|
||||
data-c1=${block.data['gradientColor1'] != null ? block.data['gradientColor1'] : '#000000'},
|
||||
data-c2=${block.data['gradientColor2'] != null ? block.data['gradientColor2'] : '#1e3a5f'},
|
||||
data-opacity=${block.data['overlayOpacity'] != null ? block.data['overlayOpacity'] : '0.5'},
|
||||
data-angle=${block.data['gradientAngle'] != null ? block.data['gradientAngle'] : '160'},
|
||||
data-rx=${block.data['gradientRadialX'] != null ? block.data['gradientRadialX'] : '50'},
|
||||
data-ry=${block.data['gradientRadialY'] != null ? block.data['gradientRadialY'] : '50'}">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var overlay = document.currentScript.previousElementSibling;
|
||||
if (!overlay || !overlay.classList.contains('sis-hero-overlay')) return;
|
||||
function hex2rgba(hex, alpha) {
|
||||
hex = (hex || '#000000').replace('#', '');
|
||||
if (hex.length === 3) hex = hex.split('').map(function(c) { return c + c; }).join('');
|
||||
var r = parseInt(hex.substring(0, 2), 16) || 0;
|
||||
var g = parseInt(hex.substring(2, 4), 16) || 0;
|
||||
var b = parseInt(hex.substring(4, 6), 16) || 0;
|
||||
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
|
||||
}
|
||||
var type = overlay.getAttribute('data-type') || 'dark_bottom';
|
||||
var c1 = overlay.getAttribute('data-c1') || '#000000';
|
||||
var c2 = overlay.getAttribute('data-c2') || '#1e3a5f';
|
||||
var op = parseFloat(overlay.getAttribute('data-opacity') || '0.5');
|
||||
var angle = overlay.getAttribute('data-angle') || '160';
|
||||
var rx = overlay.getAttribute('data-rx') || '50';
|
||||
var ry = overlay.getAttribute('data-ry') || '50';
|
||||
|
||||
var grad = 'rgba(0,0,0,' + op + ')';
|
||||
if (type === 'none') { grad = 'rgba(0,0,0,' + op + ')'; }
|
||||
else if (type === 'linear') { grad = 'linear-gradient(' + angle + 'deg, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, 0) + ')'; }
|
||||
else if (type === 'linear_both' || type === 'custom_color') { grad = 'linear-gradient(' + angle + 'deg, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, op) + ')'; }
|
||||
else if (type === 'radial') { grad = 'radial-gradient(ellipse at ' + rx + '% ' + ry + '%, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, 0) + ')'; }
|
||||
else if (type === 'radial_both') { grad = 'radial-gradient(ellipse at ' + rx + '% ' + ry + '%, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, op) + ')'; }
|
||||
else if (type === 'dark_bottom') { grad = 'linear-gradient(to top, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
|
||||
else if (type === 'dark_top') { grad = 'linear-gradient(to bottom, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
|
||||
else if (type === 'dark_left') { grad = 'linear-gradient(to right, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
|
||||
else if (type === 'dark_right') { grad = 'linear-gradient(to left, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
|
||||
else if (type === 'spotlight') { grad = 'radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0) 20%, rgba(0,0,0,' + op + ') 80%)'; }
|
||||
else if (type === 'vignette') { grad = 'radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0) 40%, rgba(0,0,0,' + op + ') 100%)'; }
|
||||
else if (type === 'duo_blue') { grad = 'linear-gradient(' + angle + 'deg, rgba(2,132,199,' + op + '), rgba(15,23,42,' + op + '))'; }
|
||||
else if (type === 'duo_purple') { grad = 'linear-gradient(' + angle + 'deg, rgba(126,34,206,' + op + '), rgba(15,23,42,' + op + '))'; }
|
||||
else if (type === 'duo_sunset') { grad = 'linear-gradient(' + angle + 'deg, rgba(220,38,38,' + op + '), rgba(249,115,22,' + op + '), rgba(234,179,8,' + op + '))'; }
|
||||
else if (type === 'duo_ocean') { grad = 'linear-gradient(' + angle + 'deg, rgba(6,182,212,' + op + '), rgba(37,99,235,' + op + '))'; }
|
||||
else if (type === 'duo_forest') { grad = 'linear-gradient(' + angle + 'deg, rgba(22,163,74,' + op + '), rgba(15,23,42,' + op + '))'; }
|
||||
else if (type === 'duo_rose') { grad = 'linear-gradient(' + angle + 'deg, rgba(244,63,94,' + op + '), rgba(251,191,36,' + op + '))'; }
|
||||
overlay.style.background = grad;
|
||||
})();
|
||||
</script>
|
||||
<div class="container" style="position: relative; z-index: 2"
|
||||
th:styleappend="${'color: ' + (block.data != null and block.data['textColor'] != null and !#strings.isEmpty(block.data['textColor']) ? block.data['textColor'] : '#ffffff') + ' !important;'}">
|
||||
<h1
|
||||
class="display-4 font-weight-bold text-white mb-3"
|
||||
class="display-4 font-weight-bold mb-3"
|
||||
th:if="${block.data['title'] != null and !#strings.isEmpty(block.data['title'])}"
|
||||
th:utext="${block.data['title']}"
|
||||
th:style="${'color: ' + (block.data != null and block.data['textColor'] != null and !#strings.isEmpty(block.data['textColor']) ? block.data['textColor'] : '#ffffff') + ' !important;'}"
|
||||
>
|
||||
Hero Banner Title
|
||||
</h1>
|
||||
<p
|
||||
class="lead text-light mb-4"
|
||||
class="lead mb-4"
|
||||
th:if="${block.data['subtitle'] != null and !#strings.isEmpty(block.data['subtitle'])}"
|
||||
th:utext="${block.data['subtitle']}"
|
||||
th:style="${'color: ' + (block.data != null and block.data['textColor'] != null and !#strings.isEmpty(block.data['textColor']) ? block.data['textColor'] : '#ffffff') + ' !important;'}"
|
||||
></p>
|
||||
<a
|
||||
th:if="${block.data['btnText'] != null and !#strings.isEmpty(block.data['btnText'])}"
|
||||
@@ -410,7 +473,7 @@
|
||||
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
|
||||
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : ''}"
|
||||
th:classappend="${block['cssClass']}"
|
||||
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
|
||||
th:styleappend="${(block.data['globalStyle'] != null and !#strings.isEmpty(block.data['globalStyle']) ? block.data['globalStyle'] + ';' : '') + (block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
|
||||
th:attr="data-custom-attrs=${block['customAttrs']}"
|
||||
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
|
||||
<div class="container py-4">
|
||||
@@ -466,6 +529,40 @@
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- Practice Cards Block (Untitled-5.html) -->
|
||||
<th:block th:if="${block['type'] == 'cards' or block['type'] == 'practiceCards' or block['type'] == 'practice-cards'}">
|
||||
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
|
||||
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : 'sis-practice-card-section'}"
|
||||
th:classappend="${block['cssClass']}"
|
||||
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
|
||||
th:attr="data-custom-attrs=${block['customAttrs']}"
|
||||
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : null}">
|
||||
<div class="container py-4">
|
||||
<div class="row g-4 align-items-stretch">
|
||||
<th:block th:each="item : ${block.data['items']}">
|
||||
<div th:if="${item['active'] == null or item['active'] == true}"
|
||||
th:class="${block.data['cols'] == 1 ? 'col-12 mb-4' : (block.data['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (block.data['cols'] == 3 ? 'col-lg-4 col-md-6 col-12 mb-4' : (block.data['cols'] == 6 ? 'col-lg-2 col-md-4 col-6 mb-4' : 'col-lg-3 col-md-6 col-12 mb-4')))}">
|
||||
<div class="practice-card">
|
||||
<div class="card-title-custom" th:utext="${item['title']}">Title</div>
|
||||
<div class="card-footer-custom">
|
||||
<span class="date-text" th:if="${item['date'] != null and !#strings.isEmpty(item['date'])}" th:text="${item['date']}">22/06/2026</span>
|
||||
<a th:href="${item['linkUrl'] != null and !#strings.isEmpty(item['linkUrl']) ? item['linkUrl'] : '#'}"
|
||||
th:target="${item['linkTarget'] != null ? item['linkTarget'] : '_self'}"
|
||||
class="action-link">
|
||||
<span th:text="${item['linkText'] != null and !#strings.isEmpty(item['linkText']) ? item['linkText'] : 'Tìm hiểu thêm'}">Tìm hiểu thêm</span>
|
||||
<div class="icon-circle">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- 18. Tab Panel Plugin Block -->
|
||||
<th:block th:if="${block['type'] == 'tabPlugin' or block['type'] == 'tab-plugin' or block['type'] == 'tab_plugin'}">
|
||||
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
|
||||
@@ -529,8 +626,24 @@
|
||||
th:attr="data-custom-attrs=${block['customAttrs']}"
|
||||
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : null}">
|
||||
<div class="container py-4">
|
||||
<!-- Shared Filter Tabs for ALL Dynamic Posts Layouts -->
|
||||
<th:block th:if="${block.data['enableTabs'] == null or block.data['enableTabs'] == true}">
|
||||
|
||||
<!-- "Xem Thêm" link: shown when showViewMore is enabled and a URL is provided -->
|
||||
<th:block th:if="${block.data['showViewMore'] == true and block.data['viewMoreUrl'] != null and !#strings.isEmpty(block.data['viewMoreUrl'])}">
|
||||
<style>
|
||||
.posts-view-more-wrap { display: flex; justify-content: flex-end; margin-bottom: 20px; }
|
||||
.posts-view-more-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 20px; background-color: #0284c7; color: #ffffff; font-size: 14px; font-weight: 600; border-radius: 6px; text-decoration: none; transition: background-color 0.2s ease, transform 0.15s ease; }
|
||||
.posts-view-more-link:hover { background-color: #0369a1; color: #ffffff; transform: translateY(-1px); text-decoration: none; }
|
||||
</style>
|
||||
<div class="posts-view-more-wrap">
|
||||
<a th:href="${block.data['viewMoreUrl']}" class="posts-view-more-link">
|
||||
Xem Thêm
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- Filter Tabs: shown when enableTabs is on AND showViewMore is NOT replacing it -->
|
||||
<th:block th:if="${(block.data['enableTabs'] == null or block.data['enableTabs'] == true) and !(block.data['showViewMore'] == true and block.data['viewMoreUrl'] != null and !#strings.isEmpty(block.data['viewMoreUrl']))}">
|
||||
<style>
|
||||
.global-filter-tabs { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 24px; justify-content: flex-start; }
|
||||
.global-filter-tabs .tab-btn { padding: 8px 18px; border: none; background-color: #f1f5f9; color: #475569; font-size: 14px; font-weight: 600; border-radius: 6px; cursor: pointer; transition: all 0.2s ease; user-select: none; }
|
||||
@@ -723,7 +836,7 @@
|
||||
) or (
|
||||
(p.category != null and block.data['categoryFilter'] != null and !#strings.isEmpty(block.data['categoryFilter']) and #strings.containsIgnoreCase(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ','))
|
||||
or
|
||||
(p.tags != null and block.data['tagFilter'] != null and !#strings.isEmpty(block.data['tagFilter']) and p.tags.?[#strings.containsIgnoreCase(',' + block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0)
|
||||
(p.tags != null and block.data['tagFilter'] != null and !#strings.isEmpty(block.data['tagFilter']) and p.tags.?[#strings.containsIgnoreCase(',' + #root.block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0)
|
||||
)}">
|
||||
<a th:href="@{'/posts/' + ${p.slug}}"
|
||||
class="news-card"
|
||||
@@ -888,7 +1001,7 @@
|
||||
<th:block th:if="${block.data['displayStyle'] == 'umc-award-hero' or block.data['displayStyle'] == 'umc-5card-award'}">
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-xl-5 g-3">
|
||||
<th:block th:if="${posts != null}">
|
||||
<th:block th:each="p : ${posts}" th:if="${(block.data['layoutFilter'] == null or block.data['layoutFilter'] == 'ALL' or (p.layout != null and #strings.equals(p.layout.name(), block.data['layoutFilter']))) and (block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or (p.category != null and #strings.containsIgnoreCase(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ','))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (p.tags != null and p.tags.?[#strings.containsIgnoreCase(',' + block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0))}">
|
||||
<th:block th:each="p : ${posts}" th:if="${(block.data['layoutFilter'] == null or block.data['layoutFilter'] == 'ALL' or (p.layout != null and #strings.equals(p.layout.name(), block.data['layoutFilter']))) and (block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or (p.category != null and #strings.containsIgnoreCase(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ','))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (p.tags != null and p.tags.?[#strings.containsIgnoreCase(',' + #root.block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0))}">
|
||||
<div class="col" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
|
||||
<a th:href="@{'/posts/' + ${p.slug}}" class="card h-100 border-0 shadow-sm rounded-lg text-decoration-none overflow-hidden group">
|
||||
<div class="ratio ratio-4x3 bg-light overflow-hidden">
|
||||
@@ -933,8 +1046,11 @@
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.news-section-v3 .featured-post {
|
||||
.news-section-v3 .featured-post-wrapper,
|
||||
.news-section-v3 .side-news-grid-wrapper {
|
||||
grid-column: span 6;
|
||||
}
|
||||
.news-section-v3 .featured-post {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -1033,6 +1149,8 @@
|
||||
color: #0284c7;
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
.news-section-v3 .featured-post-wrapper,
|
||||
.news-section-v3 .side-news-grid-wrapper,
|
||||
.news-section-v3 .featured-post,
|
||||
.news-section-v3 .side-news-grid {
|
||||
grid-column: span 12;
|
||||
@@ -1118,10 +1236,38 @@
|
||||
|
||||
<!-- Standard Grid Layout -->
|
||||
<th:block th:if="${block.data['displayStyle'] != 'umc-award-hero' and block.data['displayStyle'] != 'umc-5card-award' and block.data['displayStyle'] != 'bento_layout_01' and block.data['displayStyle'] != 'bento_layout_02' and block.data['displayStyle'] != 'card_grid_layout' and block.data['displayStyle'] != 'news_featured_2x2'}">
|
||||
<div class="row" th:classappend="${block.data['displayStyle'] == 'grid' or block.data['displayStyle'] == null ? '' : (block.data['displayStyle'] == 'compact' ? 'flex-column' : '')}">
|
||||
<style>
|
||||
.sis-posts-block .posts-items-row { display: flex; flex-wrap: wrap; margin-left: -12px; margin-right: -12px; }
|
||||
.sis-posts-block .post-item-col { padding-left: 12px; padding-right: 12px; box-sizing: border-box; }
|
||||
.sis-posts-block .card { display: flex; flex-direction: column; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); transition: transform 0.25s ease, box-shadow 0.25s ease; height: 100%; text-decoration: none !important; }
|
||||
.sis-posts-block .card:hover { transform: translateY(-4px); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.1); }
|
||||
.sis-posts-block .card-body { padding: 16px 18px 20px; display: flex; flex-direction: column; justify-content: space-between; flex-grow: 1; }
|
||||
.sis-posts-block .card-title { font-size: 15px; font-weight: 700; line-height: 1.45; margin-bottom: 8px; margin-top: 4px; }
|
||||
.sis-posts-block .card-title a { color: #0f172a !important; text-decoration: none !important; transition: color 0.2s ease; font-weight: 700 !important; }
|
||||
.sis-posts-block .card-title a:hover { color: #0284c7 !important; text-decoration: none !important; }
|
||||
.sis-posts-block .badge-primary { background-color: #0284c7 !important; color: #ffffff !important; font-size: 11px; font-weight: 600; padding: 4px 10px; border-radius: 20px; display: inline-block; text-decoration: none !important; }
|
||||
.sis-posts-block .pagination-container,
|
||||
.sis-posts-block ul.pagination { display: flex !important; justify-content: center !important; align-items: center !important; list-style: none !important; list-style-type: none !important; gap: 8px !important; margin-top: 28px !important; margin-bottom: 0 !important; padding-left: 0 !important; padding-right: 0 !important; width: 100% !important; flex-direction: row !important; }
|
||||
.sis-posts-block .pagination-container li,
|
||||
.sis-posts-block .pagination-container .page-item,
|
||||
.sis-posts-block ul.pagination li { list-style: none !important; list-style-type: none !important; margin: 0 !important; padding: 0 !important; display: inline-flex !important; }
|
||||
.sis-posts-block .pagination-container .page-link,
|
||||
.sis-posts-block ul.pagination .page-link { display: inline-flex !important; justify-content: center !important; align-items: center !important; min-width: 38px !important; height: 38px !important; padding: 0 12px !important; border-radius: 8px !important; border: 1px solid #cbd5e1 !important; background-color: #ffffff !important; color: #475569 !important; text-decoration: none !important; font-size: 14px !important; font-weight: 600 !important; transition: all 0.2s ease !important; cursor: pointer !important; user-select: none !important; line-height: 1 !important; }
|
||||
.sis-posts-block .pagination-container .page-link:hover,
|
||||
.sis-posts-block ul.pagination .page-link:hover { border-color: #0284c7 !important; color: #0284c7 !important; background-color: #f0f9ff !important; text-decoration: none !important; }
|
||||
.sis-posts-block .pagination-container .page-item.active .page-link,
|
||||
.sis-posts-block .pagination-container .page-link.active,
|
||||
.sis-posts-block ul.pagination li.active .page-link { background-color: #0284c7 !important; color: #ffffff !important; border-color: #0284c7 !important; box-shadow: 0 2px 6px rgba(2, 132, 199, 0.3) !important; }
|
||||
.sis-posts-block .pagination-container .page-item.disabled .page-link,
|
||||
.sis-posts-block ul.pagination li.disabled .page-link { opacity: 0.45 !important; pointer-events: none !important; cursor: not-allowed !important; }
|
||||
</style>
|
||||
<div class="row posts-items-row"
|
||||
th:classappend="${block.data['displayStyle'] == 'grid' or block.data['displayStyle'] == null ? '' : (block.data['displayStyle'] == 'compact' ? 'flex-column' : '')}"
|
||||
th:data-limit="${block.data['limit'] != null and block.data['limit'] > 0 ? block.data['limit'] : (block.data['itemsPerPage'] != null and block.data['itemsPerPage'] > 0 ? block.data['itemsPerPage'] : 12)}"
|
||||
th:data-enable-pagination="${block.data['enablePagination'] == null or block.data['enablePagination'] == true}">
|
||||
<th:block th:if="${posts != null}">
|
||||
<th:block th:each="p : ${posts}" th:if="${(block.data['layoutFilter'] == null or block.data['layoutFilter'] == 'ALL' or (p.layout != null and #strings.equals(p.layout.name(), block.data['layoutFilter']))) and (block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or (p.category != null and #strings.containsIgnoreCase(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ','))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (p.tags != null and p.tags.?[#strings.containsIgnoreCase(',' + block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0))}">
|
||||
<div th:class="${block.data['cols'] == 1 ? 'col-12 mb-4' : (block.data['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (block.data['cols'] == 4 ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg-4 col-md-6 col-12 mb-4'))}" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
|
||||
<th:block th:each="p : ${posts}" th:if="${(block.data['layoutFilter'] == null or block.data['layoutFilter'] == 'ALL' or (p.layout != null and #strings.equals(p.layout.name(), block.data['layoutFilter']))) and (block.data['categoryFilter'] == null or #strings.isEmpty(block.data['categoryFilter']) or (p.category != null and #strings.containsIgnoreCase(',' + block.data['categoryFilter'] + ',', ',' + p.category.name + ','))) and (block.data['tagFilter'] == null or #strings.isEmpty(block.data['tagFilter']) or (p.tags != null and p.tags.?[#strings.containsIgnoreCase(',' + #root.block.data['tagFilter'] + ',', ',' + name + ',')].size() > 0))}">
|
||||
<div class="post-item-col" th:classappend="${block.data['cols'] == 1 ? 'col-12 mb-4' : (block.data['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (block.data['cols'] == 4 ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg-4 col-md-6 col-12 mb-4'))}" th:data-category="${p.category != null ? p.category.name : ''}" th:data-tags="${p.tags != null and !#sets.isEmpty(p.tags) ? #strings.listJoin(p.tags.![name], ',') : ''}">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-lg overflow-hidden">
|
||||
<div th:if="${(block.data['showImage'] == null or block.data['showImage'] == true) and p.featuredImage != null and !#strings.isEmpty(p.featuredImage)}" style="height: 200px; overflow: hidden; background: #f8f9fa;">
|
||||
<img th:src="${p.featuredImage}" th:alt="${p.title}" class="w-100 h-100" style="object-fit: cover;" />
|
||||
@@ -1146,47 +1292,47 @@
|
||||
</th:block>
|
||||
</th:block>
|
||||
</div>
|
||||
<ul class="pagination pagination-container justify-content-center mt-3 mb-0" th:if="${block.data['enablePagination'] == null or block.data['enablePagination'] == true}"></ul>
|
||||
</th:block>
|
||||
|
||||
<!-- Universal JS Initializer for Filter Tabs Across ALL Posts Layouts -->
|
||||
<script th:if="${block.data['enableTabs'] == null or block.data['enableTabs'] == true}">
|
||||
<!-- Universal JS Initializer for Filter Tabs & Pagination Across ALL Posts Layouts -->
|
||||
<script>
|
||||
(function () {
|
||||
function initGlobalPostsBlockTabs() {
|
||||
function initGlobalPostsBlock() {
|
||||
const blocks = document.querySelectorAll('.sis-posts-block');
|
||||
blocks.forEach(function (block) {
|
||||
if (block.dataset.globalTabsInitialized) return;
|
||||
block.dataset.globalTabsInitialized = 'true';
|
||||
if (block.dataset.globalPostsInitialized) return;
|
||||
block.dataset.globalPostsInitialized = 'true';
|
||||
|
||||
const tabsContainer = block.querySelector('.filter-tabs');
|
||||
if (!tabsContainer) return;
|
||||
if (tabsContainer) {
|
||||
const rawConfigured = tabsContainer.getAttribute('data-configured-tabs') || '';
|
||||
let tabNames = rawConfigured.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
|
||||
tabNames = tabNames.filter(function(name) { return name !== 'ALL' && name !== ''; });
|
||||
|
||||
const rawConfigured = tabsContainer.getAttribute('data-configured-tabs') || '';
|
||||
let tabNames = rawConfigured.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
|
||||
if (tabNames.length === 0) {
|
||||
const foundSet = new Set();
|
||||
const items = block.querySelectorAll('[data-category], [data-tags]');
|
||||
items.forEach(function (item) {
|
||||
const cat = item.getAttribute('data-category');
|
||||
if (cat && cat.trim()) foundSet.add(cat.trim());
|
||||
const tags = (item.getAttribute('data-tags') || '').split(',');
|
||||
tags.forEach(function (t) { if (t.trim()) foundSet.add(t.trim()); });
|
||||
});
|
||||
tabNames = Array.from(foundSet);
|
||||
}
|
||||
|
||||
tabNames = tabNames.filter(function(name) { return name !== 'ALL' && name !== ''; });
|
||||
|
||||
if (tabNames.length === 0) {
|
||||
const foundSet = new Set();
|
||||
const items = block.querySelectorAll('[data-category], [data-tags]');
|
||||
items.forEach(function (item) {
|
||||
const cat = item.getAttribute('data-category');
|
||||
if (cat && cat.trim()) foundSet.add(cat.trim());
|
||||
const tags = (item.getAttribute('data-tags') || '').split(',');
|
||||
tags.forEach(function (t) { if (t.trim()) foundSet.add(t.trim()); });
|
||||
});
|
||||
tabNames = Array.from(foundSet);
|
||||
}
|
||||
|
||||
if (tabsContainer.querySelectorAll('.tab-btn[data-filter]').length <= 1) {
|
||||
tabNames.forEach(function (name) {
|
||||
if (name === 'ALL') return;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'tab-btn';
|
||||
btn.setAttribute('data-filter', name);
|
||||
btn.textContent = name;
|
||||
tabsContainer.appendChild(btn);
|
||||
});
|
||||
if (tabsContainer.querySelectorAll('.tab-btn[data-filter]').length <= 1) {
|
||||
tabNames.forEach(function (name) {
|
||||
if (name === 'ALL') return;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'tab-btn';
|
||||
btn.setAttribute('data-filter', name);
|
||||
btn.textContent = name;
|
||||
tabsContainer.appendChild(btn);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateBento02Layout(activeFilter) {
|
||||
@@ -1287,49 +1433,131 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Initial Bento 02 layout rendering
|
||||
// Initialize Bento 02 layout if present
|
||||
updateBento02Layout('ALL');
|
||||
|
||||
tabsContainer.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.tab-btn');
|
||||
if (!btn) return;
|
||||
const activeFilter = btn.getAttribute('data-filter');
|
||||
// Pagination & Limit Initializer for Standard Grid Rows
|
||||
const itemsRow = block.querySelector('.posts-items-row');
|
||||
if (itemsRow) {
|
||||
const limit = parseInt(itemsRow.getAttribute('data-limit')) || 12;
|
||||
const enablePagination = itemsRow.getAttribute('data-enable-pagination') !== 'false';
|
||||
const paginationUl = block.querySelector('.pagination-container');
|
||||
const allCols = Array.from(itemsRow.querySelectorAll('.post-item-col'));
|
||||
let currentPage = 1;
|
||||
let currentFilter = 'ALL';
|
||||
|
||||
tabsContainer.querySelectorAll('.tab-btn').forEach(function (b) {
|
||||
if (b.getAttribute('data-filter') === activeFilter) {
|
||||
b.classList.add('active');
|
||||
function renderGridPage() {
|
||||
const visibleCols = allCols.filter(function (col) {
|
||||
if (currentFilter === 'ALL') return true;
|
||||
const target = currentFilter.trim().toLowerCase();
|
||||
const cat = (col.getAttribute('data-category') || '').trim().toLowerCase();
|
||||
const tags = (col.getAttribute('data-tags') || '').split(',').map(function (s) { return s.trim().toLowerCase(); });
|
||||
return cat === target || tags.includes(target);
|
||||
});
|
||||
|
||||
if (enablePagination) {
|
||||
const totalPages = Math.ceil(visibleCols.length / limit) || 1;
|
||||
if (currentPage > totalPages) currentPage = totalPages;
|
||||
|
||||
allCols.forEach(function (c) { c.style.display = 'none'; });
|
||||
const startIdx = (currentPage - 1) * limit;
|
||||
const endIdx = startIdx + limit;
|
||||
visibleCols.slice(startIdx, endIdx).forEach(function (c) { c.style.display = ''; });
|
||||
|
||||
if (paginationUl) {
|
||||
paginationUl.innerHTML = '';
|
||||
if (totalPages <= 1) return;
|
||||
|
||||
const prevLi = document.createElement('li');
|
||||
prevLi.className = 'page-item' + (currentPage === 1 ? ' disabled' : '');
|
||||
prevLi.innerHTML = '<a class="page-link" href="javascript:void(0)">«</a>';
|
||||
prevLi.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) { currentPage--; renderGridPage(); }
|
||||
});
|
||||
paginationUl.appendChild(prevLi);
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'page-item' + (i === currentPage ? ' active' : '');
|
||||
li.innerHTML = '<a class="page-link" href="javascript:void(0)">' + i + '</a>';
|
||||
li.addEventListener('click', (function (pageNum) {
|
||||
return function (e) {
|
||||
e.preventDefault();
|
||||
currentPage = pageNum;
|
||||
renderGridPage();
|
||||
};
|
||||
})(i));
|
||||
paginationUl.appendChild(li);
|
||||
}
|
||||
|
||||
const nextLi = document.createElement('li');
|
||||
nextLi.className = 'page-item' + (currentPage === totalPages ? ' disabled' : '');
|
||||
nextLi.innerHTML = '<a class="page-link" href="javascript:void(0)">»</a>';
|
||||
nextLi.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages) { currentPage++; renderGridPage(); }
|
||||
});
|
||||
paginationUl.appendChild(nextLi);
|
||||
}
|
||||
} else {
|
||||
b.classList.remove('active');
|
||||
allCols.forEach(function (c) { c.style.display = 'none'; });
|
||||
visibleCols.slice(0, limit).forEach(function (c) { c.style.display = ''; });
|
||||
if (paginationUl) paginationUl.innerHTML = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update Bento 02 Layout if present
|
||||
updateBento02Layout(activeFilter);
|
||||
if (tabsContainer) {
|
||||
tabsContainer.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.tab-btn');
|
||||
if (!btn) return;
|
||||
currentFilter = btn.getAttribute('data-filter') || 'ALL';
|
||||
currentPage = 1;
|
||||
renderGridPage();
|
||||
});
|
||||
}
|
||||
|
||||
// Update standard grid & cards
|
||||
const postItems = Array.from(block.querySelectorAll('.news-card, .bento-item, [data-category]:not(.featured-post):not(.small-post)'));
|
||||
postItems.forEach(function (item) {
|
||||
if (activeFilter === 'ALL') {
|
||||
item.style.display = '';
|
||||
return;
|
||||
}
|
||||
const targetFilter = activeFilter.trim().toLowerCase();
|
||||
const itemCat = (item.getAttribute('data-category') || '').trim().toLowerCase();
|
||||
const itemTags = (item.getAttribute('data-tags') || '').split(',').map(function (s) { return s.trim().toLowerCase(); });
|
||||
if (itemCat === targetFilter || itemTags.includes(targetFilter)) {
|
||||
item.style.display = '';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
renderGridPage();
|
||||
} else if (tabsContainer) {
|
||||
tabsContainer.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.tab-btn');
|
||||
if (!btn) return;
|
||||
const activeFilter = btn.getAttribute('data-filter');
|
||||
|
||||
tabsContainer.querySelectorAll('.tab-btn').forEach(function (b) {
|
||||
if (b.getAttribute('data-filter') === activeFilter) {
|
||||
b.classList.add('active');
|
||||
} else {
|
||||
b.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
updateBento02Layout(activeFilter);
|
||||
|
||||
const postItems = Array.from(block.querySelectorAll('.news-card, .bento-item, [data-category]:not(.featured-post):not(.small-post)'));
|
||||
postItems.forEach(function (item) {
|
||||
if (activeFilter === 'ALL') {
|
||||
item.style.display = '';
|
||||
return;
|
||||
}
|
||||
const targetFilter = activeFilter.trim().toLowerCase();
|
||||
const itemCat = (item.getAttribute('data-category') || '').trim().toLowerCase();
|
||||
const itemTags = (item.getAttribute('data-tags') || '').split(',').map(function (s) { return s.trim().toLowerCase(); });
|
||||
if (itemCat === targetFilter || itemTags.includes(targetFilter)) {
|
||||
item.style.display = '';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
||||
initGlobalPostsBlockTabs();
|
||||
initGlobalPostsBlock();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', initGlobalPostsBlockTabs);
|
||||
document.addEventListener('DOMContentLoaded', initGlobalPostsBlock);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -1337,37 +1565,170 @@
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- 20. Form & Tri Ân Block (Interactive Custom Form & Google Form) -->
|
||||
<th:block th:if="${block['type'] == 'googleForm' or block['type'] == 'google_form' or block['type'] == 'google-form'}">
|
||||
<div class="sis-google-form-container my-4"
|
||||
th:classappend="${((block.data != null and (block.data['stretched'] == true or block.data['fullWidth'] == true)) or block['stretched'] == true or block['fullWidth'] == true ? 'sis-fullwidth-breakout px-0 w-100 ' : '') + (block['cssClass'] != null ? block['cssClass'] : '')}"
|
||||
th:id="${block['elementId']}"
|
||||
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
|
||||
th:attr="data-custom-attrs=${block['customAttrs']}">
|
||||
|
||||
<!-- Mode A: Google Form iFrame Embed -->
|
||||
<th:block th:if="${block.data['formType'] == 'google_iframe'}">
|
||||
<div class="p-3 p-sm-4 text-center"
|
||||
th:style="${'border-radius:16px; background: ' + (block.data['theme'] == 'red_gradient' ? 'linear-gradient(135deg, #701a1a 0%, #d7193f 50%, #fff0f3 100%)' : (block.data['theme'] == 'teal_gradient' ? 'linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #f0fdfa 100%)' : (block.data['theme'] == 'light' ? '#f1f5f9' : 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #eff6ff 100%)')))}">
|
||||
<iframe th:if="${block.data['iframeUrl'] != null and !#strings.isEmpty(block.data['iframeUrl'])}"
|
||||
th:src="${block.data['iframeUrl']}"
|
||||
style="width: 100%; border: none; border-radius: 12px; background: #ffffff; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);"
|
||||
th:styleappend="${'height: ' + (block.data['iframeHeight'] != null and !#strings.isEmpty(block.data['iframeHeight']) ? block.data['iframeHeight'] : '650px') + ';'}"></iframe>
|
||||
<th:block th:unless="${block.data['iframeUrl'] != null and !#strings.isEmpty(block.data['iframeUrl'])}">
|
||||
<div th:utext="${hookManager.applyFilters('the_content', '[plugin:google-form' + (block.data['formId'] != null ? ' id="' + block.data['formId'] + '"' : (block.data['id'] != null ? ' id="' + block.data['id'] + '"' : '')) + ']')}" th:remove="tag"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
|
||||
<!-- Mode B: Form Chia Sẻ Lời Tri Ân (Matching temp.html) -->
|
||||
<th:block th:if="${block.data['formType'] == null or block.data['formType'] == 'tri_an'}">
|
||||
<div class="p-3 p-sm-4 p-md-5 d-flex align-items-center justify-content-center"
|
||||
th:style="${'min-height: 100%; border-radius: 20px; background: ' + (block.data['theme'] == 'red_gradient' ? 'linear-gradient(135deg, #701a1a 0%, #d7193f 50%, #fff0f3 100%)' : (block.data['theme'] == 'teal_gradient' ? 'linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #f0fdfa 100%)' : (block.data['theme'] == 'light' ? '#f1f5f9' : 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #eff6ff 100%)')))}">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-100 p-4 p-sm-4 p-md-5 my-auto" style="max-width: 580px; border-radius: 16px; box-shadow: 0 20px 40px rgba(0,0,0,0.15);">
|
||||
<h2 class="h4 font-weight-bold mb-3"
|
||||
th:style="${'color: ' + (block.data['theme'] == 'red_gradient' ? '#881C1C' : (block.data['theme'] == 'teal_gradient' ? '#0f766e' : (block.data['theme'] == 'light' ? '#1e293b' : '#1e3a8a')))}"
|
||||
th:text="${block.data['title'] != null and !#strings.isEmpty(block.data['title']) ? block.data['title'] : 'Chia sẻ lời tri ân'}">Chia sẻ lời tri ân</h2>
|
||||
<p class="text-muted small mb-4" th:if="${block.data['subtitle'] != null and !#strings.isEmpty(block.data['subtitle'])}" th:utext="${block.data['subtitle']}"></p>
|
||||
|
||||
<form class="sis-tri-an-form"
|
||||
th:data-action-url="${block.data['actionUrl']}"
|
||||
th:data-success-msg="${block.data['successMsg'] != null and !#strings.isEmpty(block.data['successMsg']) ? block.data['successMsg'] : 'Gửi thông tin thành công!'}"
|
||||
onsubmit="event.preventDefault(); var msg = this.getAttribute('data-success-msg'); alert(msg); this.reset();">
|
||||
<div class="mb-3">
|
||||
<label class="form-label font-weight-bold text-secondary small mb-1">Họ tên <span class="text-danger">*</span></label>
|
||||
<input type="text" name="fullName" required placeholder="Nhập họ tên" class="form-control form-control-lg border-gray-300 rounded-lg text-sm" />
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-sm-6">
|
||||
<label class="form-label font-weight-bold text-secondary small mb-1">Bạn là <span class="text-danger">*</span></label>
|
||||
<select name="role" required class="form-select form-control form-control-lg border-gray-300 rounded-lg text-sm bg-white text-gray-500">
|
||||
<option value="" disabled selected>Chọn vai trò</option>
|
||||
<option value="bac-si">Bác sĩ / Nhân viên y tế</option>
|
||||
<option value="benh-nhan">Bệnh nhân / Người nhà</option>
|
||||
<option value="khac">Khác</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<label class="form-label font-weight-bold text-secondary small mb-1">Chức vụ/ Nơi làm việc <span class="text-danger">*</span></label>
|
||||
<input type="text" name="workplace" required placeholder="Nhập chức vụ/ nơi làm việc" class="form-control form-control-lg border-gray-300 rounded-lg text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label font-weight-bold text-secondary small mb-1">Số điện thoại <span class="text-danger">*</span></label>
|
||||
<input type="tel" name="phone" required placeholder="Nhập số điện thoại" class="form-control form-control-lg border-gray-300 rounded-lg text-sm" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label font-weight-bold text-secondary small mb-1">Nội dung lời nhắn <span class="text-danger">*</span></label>
|
||||
<textarea name="message" rows="3" required placeholder="Nhập nội dung" class="form-control border-gray-300 rounded-lg text-sm" style="resize:none;"></textarea>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label font-weight-bold text-secondary small mb-1">Hình ảnh <span class="text-danger">*</span></label>
|
||||
<label class="d-flex flex-column align-items-center justify-content-center border border-2 border-dashed border-gray-300 rounded-lg cursor-pointer bg-light p-3 text-muted hover-border-primary" style="width: 110px; height: 110px; cursor: pointer;">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" class="mb-1"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
||||
<span class="small" style="font-size: 11px;">Tải tệp</span>
|
||||
<input type="file" name="image" accept="image/*" class="d-none" required onchange="if(this.files && this.files[0]){ this.previousElementSibling.textContent = this.files[0].name.length > 12 ? this.files[0].name.substring(0,9)+'...' : this.files[0].name; }" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="pt-2">
|
||||
<button type="submit" class="btn text-white font-weight-bold px-4 py-2 rounded-lg shadow-sm w-100 w-sm-auto"
|
||||
th:style="${block.data['theme'] == 'red_gradient' ? 'background:#d7193f;' : (block.data['theme'] == 'teal_gradient' ? 'background:#0d9488;' : (block.data['theme'] == 'light' ? 'background:#1e293b;' : 'background:#2563eb;'))}"
|
||||
th:text="${block.data['buttonText'] != null and !#strings.isEmpty(block.data['buttonText']) ? block.data['buttonText'] : 'Gửi thông tin'}">Gửi thông tin</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- Mode C: Contact Form -->
|
||||
<th:block th:if="${block.data['formType'] == 'contact'}">
|
||||
<div class="p-3 p-sm-4 p-md-5 d-flex align-items-center justify-content-center"
|
||||
th:style="${'min-height: 100%; border-radius: 20px; background: ' + (block.data['theme'] == 'red_gradient' ? 'linear-gradient(135deg, #701a1a 0%, #d7193f 50%, #fff0f3 100%)' : (block.data['theme'] == 'teal_gradient' ? 'linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #f0fdfa 100%)' : (block.data['theme'] == 'light' ? '#f1f5f9' : 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #eff6ff 100%)')))}">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-100 p-4 p-sm-4 p-md-5 my-auto" style="max-width: 580px; border-radius: 16px; box-shadow: 0 20px 40px rgba(0,0,0,0.15);">
|
||||
<h2 class="h4 font-weight-bold mb-3"
|
||||
th:style="${'color: ' + (block.data['theme'] == 'red_gradient' ? '#881C1C' : (block.data['theme'] == 'teal_gradient' ? '#0f766e' : (block.data['theme'] == 'light' ? '#1e293b' : '#1e3a8a')))}"
|
||||
th:text="${block.data['title'] != null and !#strings.isEmpty(block.data['title']) ? block.data['title'] : 'Form Liên hệ & Góp ý'}">Form Liên hệ & Góp ý</h2>
|
||||
<p class="text-muted small mb-4" th:if="${block.data['subtitle'] != null and !#strings.isEmpty(block.data['subtitle'])}" th:utext="${block.data['subtitle']}"></p>
|
||||
|
||||
<form class="sis-contact-form"
|
||||
th:data-action-url="${block.data['actionUrl']}"
|
||||
th:data-success-msg="${block.data['successMsg'] != null and !#strings.isEmpty(block.data['successMsg']) ? block.data['successMsg'] : 'Gửi thông tin thành công!'}"
|
||||
onsubmit="event.preventDefault(); var msg = this.getAttribute('data-success-msg'); alert(msg); this.reset();">
|
||||
<div class="mb-3">
|
||||
<label class="form-label font-weight-bold text-secondary small mb-1">Họ và tên <span class="text-danger">*</span></label>
|
||||
<input type="text" name="fullName" required placeholder="Nhập họ và tên" class="form-control form-control-lg border-gray-300 rounded-lg text-sm" />
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-sm-6">
|
||||
<label class="form-label font-weight-bold text-secondary small mb-1">Email <span class="text-danger">*</span></label>
|
||||
<input type="email" name="email" required placeholder="email@example.com" class="form-control form-control-lg border-gray-300 rounded-lg text-sm" />
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<label class="form-label font-weight-bold text-secondary small mb-1">Số điện thoại</label>
|
||||
<input type="tel" name="phone" placeholder="Nhập số điện thoại" class="form-control form-control-lg border-gray-300 rounded-lg text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label font-weight-bold text-secondary small mb-1">Nội dung tin nhắn <span class="text-danger">*</span></label>
|
||||
<textarea name="message" rows="4" required placeholder="Nhập nội dung tin nhắn cần hỗ trợ..." class="form-control border-gray-300 rounded-lg text-sm" style="resize:none;"></textarea>
|
||||
</div>
|
||||
<div class="pt-2">
|
||||
<button type="submit" class="btn text-white font-weight-bold px-4 py-2 rounded-lg shadow-sm w-100 w-sm-auto"
|
||||
th:style="${block.data['theme'] == 'red_gradient' ? 'background:#d7193f;' : (block.data['theme'] == 'teal_gradient' ? 'background:#0d9488;' : (block.data['theme'] == 'light' ? 'background:#1e293b;' : 'background:#2563eb;'))}"
|
||||
th:text="${block.data['buttonText'] != null and !#strings.isEmpty(block.data['buttonText']) ? block.data['buttonText'] : 'Gửi thông tin'}">Gửi thông tin</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- Shared Cell Content Renderer Fragment -->
|
||||
<th:block th:fragment="renderCell(type, i, blockData)" th:with="cellObj=${(blockData != null and blockData['content'] instanceof T(java.util.Map)) ? blockData['content'] : (blockData != null and blockData['col' + i] instanceof T(java.util.Map) ? blockData['col' + i] : blockData)}">
|
||||
<!-- Type 1: HTML / Text -->
|
||||
<th:block th:if="${type == null or type == 'html'}">
|
||||
<th:block th:if="${blockData != null}">
|
||||
<th:block th:if="${blockData['content'] != null}">
|
||||
<th:block th:if="${blockData['content'] instanceof T(java.lang.String)}" th:utext="${blockData['content']}"></th:block>
|
||||
<th:block th:unless="${blockData['content'] instanceof T(java.lang.String)}" th:utext="${blockData['content']['html']}"></th:block>
|
||||
</th:block>
|
||||
<th:block th:unless="${blockData['content'] != null}">
|
||||
<th:block th:if="${blockData['col' + i] != null}">
|
||||
<th:block th:if="${blockData['col' + i] instanceof T(java.lang.String)}" th:utext="${blockData['col' + i]}"></th:block>
|
||||
<th:block th:unless="${blockData['col' + i] instanceof T(java.lang.String)}" th:utext="${blockData['col' + i]['html']}"></th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
<th:block th:with="
|
||||
htmlVal=${cellObj instanceof T(java.util.Map) ? (cellObj['html'] != null ? cellObj['html'] : (cellObj['content'] != null ? cellObj['content'] : '')) : (blockData != null and blockData['col' + i] instanceof T(java.lang.String) ? blockData['col' + i] : (blockData != null and blockData['content'] instanceof T(java.lang.String) ? blockData['content'] : (cellObj instanceof T(java.lang.String) ? cellObj : '')))},
|
||||
hClass=${cellObj instanceof T(java.util.Map) and cellObj['cssClass'] != null ? cellObj['cssClass'] : (cellObj instanceof T(java.util.Map) and cellObj['class'] != null ? cellObj['class'] : '')},
|
||||
hId=${cellObj instanceof T(java.util.Map) and cellObj['elementId'] != null and !#strings.isEmpty(cellObj['elementId']) ? cellObj['elementId'] : (cellObj instanceof T(java.util.Map) and cellObj['id'] != null and !#strings.isEmpty(cellObj['id']) ? cellObj['id'] : null)},
|
||||
hStyle=${(cellObj instanceof T(java.util.Map) and cellObj['customStyle'] != null ? cellObj['customStyle'] + ';' : '') + (cellObj instanceof T(java.util.Map) and cellObj['style'] != null ? cellObj['style'] + ';' : '')},
|
||||
hAttrs=${cellObj instanceof T(java.util.Map) and cellObj['customAttrs'] != null and !#strings.isEmpty(cellObj['customAttrs']) ? cellObj['customAttrs'] : (cellObj instanceof T(java.util.Map) and cellObj['customAttributes'] != null ? cellObj['customAttributes'] : null)}
|
||||
">
|
||||
<div th:class="${#strings.trim(hClass)}"
|
||||
th:id="${hId}"
|
||||
th:style="${#strings.trim(hStyle)}"
|
||||
th:attr="data-custom-attrs=${hAttrs}"
|
||||
th:remove="${#strings.isEmpty(hClass) and #strings.isEmpty(hId) and #strings.isEmpty(hStyle) and #strings.isEmpty(hAttrs) ? 'tag' : 'none'}">
|
||||
<div th:utext="${htmlVal}" th:remove="tag"></div>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- Type: TinyMCE -->
|
||||
<th:block th:if="${type == 'tinymce'}">
|
||||
<th:block th:if="${blockData != null}">
|
||||
<th:block th:if="${blockData['content'] != null}">
|
||||
<th:block th:if="${blockData['content'] instanceof T(java.lang.String)}" th:utext="${blockData['content']}"></th:block>
|
||||
<th:block th:unless="${blockData['content'] instanceof T(java.lang.String)}" th:utext="${blockData['content']['html']}"></th:block>
|
||||
</th:block>
|
||||
<th:block th:unless="${blockData['content'] != null}">
|
||||
<th:block th:if="${blockData['col' + i] != null}">
|
||||
<th:block th:if="${blockData['col' + i] instanceof T(java.lang.String)}" th:utext="${blockData['col' + i]}"></th:block>
|
||||
<th:block th:unless="${blockData['col' + i] instanceof T(java.lang.String)}" th:utext="${blockData['col' + i]['html']}"></th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
<th:block th:with="
|
||||
mceVal=${cellObj instanceof T(java.util.Map) ? (cellObj['html'] != null ? cellObj['html'] : (cellObj['content'] != null ? cellObj['content'] : '')) : (blockData != null and blockData['col' + i] instanceof T(java.lang.String) ? blockData['col' + i] : (blockData != null and blockData['content'] instanceof T(java.lang.String) ? blockData['content'] : (cellObj instanceof T(java.lang.String) ? cellObj : '')))},
|
||||
mClass=${(cellObj instanceof T(java.util.Map) and cellObj['cssClass'] != null ? cellObj['cssClass'] : (cellObj instanceof T(java.util.Map) and cellObj['class'] != null ? cellObj['class'] : '')) + (blockData != null and blockData['cssClass'] != null ? ' ' + blockData['cssClass'] : '')},
|
||||
mId=${cellObj instanceof T(java.util.Map) and cellObj['elementId'] != null and !#strings.isEmpty(cellObj['elementId']) ? cellObj['elementId'] : (cellObj instanceof T(java.util.Map) and cellObj['id'] != null and !#strings.isEmpty(cellObj['id']) ? cellObj['id'] : (blockData != null and blockData['elementId'] != null ? blockData['elementId'] : null))},
|
||||
mStyle=${(cellObj instanceof T(java.util.Map) and cellObj['customStyle'] != null ? cellObj['customStyle'] + ';' : '') + (cellObj instanceof T(java.util.Map) and cellObj['style'] != null ? cellObj['style'] + ';' : '') + (blockData != null and blockData['customStyle'] != null ? blockData['customStyle'] + ';' : '') + (cellObj instanceof T(java.util.Map) and cellObj['alignment'] != null ? 'text-align: ' + cellObj['alignment'] + ';' : '')},
|
||||
mAttrs=${cellObj instanceof T(java.util.Map) and cellObj['customAttrs'] != null and !#strings.isEmpty(cellObj['customAttrs']) ? cellObj['customAttrs'] : (cellObj instanceof T(java.util.Map) and cellObj['customAttributes'] != null ? cellObj['customAttributes'] : (blockData != null and blockData['customAttrs'] != null ? blockData['customAttrs'] : null))}
|
||||
">
|
||||
<div th:class="${#strings.trim(mClass)}"
|
||||
th:id="${mId}"
|
||||
th:style="${#strings.trim(mStyle)}"
|
||||
th:attr="data-custom-attrs=${mAttrs}"
|
||||
th:remove="${#strings.isEmpty(mClass) and #strings.isEmpty(mId) and #strings.isEmpty(mStyle) and #strings.isEmpty(mAttrs) ? 'tag' : 'none'}">
|
||||
<div th:utext="${mceVal}" th:remove="tag"></div>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
@@ -1437,6 +1798,113 @@
|
||||
</details>
|
||||
</th:block>
|
||||
|
||||
<!-- Nested Plugin: Form & Tri Ân (googleForm) -->
|
||||
<th:block th:if="${type == 'googleForm' or type == 'google_form' or type == 'google-form'}">
|
||||
<div class="sis-google-form-container my-3" th:with="formObj=${cellObj != null ? cellObj : (blockData != null ? blockData['col' + i] : null)}">
|
||||
<th:block th:if="${formObj != null}">
|
||||
<th:block th:if="${formObj['formType'] == 'google_iframe'}">
|
||||
<iframe th:if="${formObj['iframeUrl'] != null and !#strings.isEmpty(formObj['iframeUrl'])}"
|
||||
th:src="${formObj['iframeUrl']}"
|
||||
style="width: 100%; border: none; border-radius: 12px; background: #ffffff;"
|
||||
th:styleappend="${'height: ' + (formObj['iframeHeight'] != null and !#strings.isEmpty(formObj['iframeHeight']) ? formObj['iframeHeight'] : '650px') + ';'}"></iframe>
|
||||
</th:block>
|
||||
<th:block th:unless="${formObj['formType'] == 'google_iframe'}">
|
||||
<div class="p-4 bg-white rounded-xl shadow-md border" style="border-radius:12px;">
|
||||
<h4 class="font-weight-bold mb-2 text-primary" th:text="${formObj['title'] != null and !#strings.isEmpty(formObj['title']) ? formObj['title'] : 'Form Liên hệ / Tri ân'}">Form</h4>
|
||||
<p class="text-muted small mb-3" th:if="${formObj['subtitle'] != null and !#strings.isEmpty(formObj['subtitle'])}" th:utext="${formObj['subtitle']}"></p>
|
||||
<form onsubmit="event.preventDefault(); alert(this.getAttribute('data-success-msg') || 'Gửi thông tin thành công!'); this.reset();" th:data-success-msg="${formObj['successMsg'] != null ? formObj['successMsg'] : 'Gửi thông tin thành công!'}">
|
||||
<div class="mb-2"><input type="text" name="fullName" required placeholder="Họ và tên" class="form-control form-control-sm" /></div>
|
||||
<div class="mb-2"><input type="tel" name="phone" required placeholder="Số điện thoại" class="form-control form-control-sm" /></div>
|
||||
<div class="mb-2"><textarea name="message" rows="3" required placeholder="Nội dung" class="form-control form-control-sm" style="resize:none;"></textarea></div>
|
||||
<button type="submit" class="btn btn-primary btn-sm font-weight-bold" th:text="${formObj['buttonText'] != null ? formObj['buttonText'] : 'Gửi thông tin'}">Gửi thông tin</button>
|
||||
</form>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- Nested Plugin: Hero Banner -->
|
||||
<th:block th:if="${type == 'hero'}">
|
||||
<th:block th:with="heroObj=${cellObj != null ? cellObj : (blockData != null ? blockData['col' + i] : null)}">
|
||||
<div th:if="${heroObj != null}"
|
||||
class="sis-hero-banner text-white py-5 px-3 rounded shadow-sm"
|
||||
th:classappend="${(heroObj['stretched'] == true or heroObj['stretched'] == 'true' ? 'sis-fullwidth-breakout ' : '') + (heroObj['textAlign'] == 'left' ? 'text-left ' : (heroObj['textAlign'] == 'right' ? 'text-right ' : 'text-center ')) + (heroObj['cssClass'] != null ? heroObj['cssClass'] : '')}"
|
||||
th:id="${heroObj['elementId'] != null ? heroObj['elementId'] : null}"
|
||||
th:attr="data-custom-attrs=${heroObj['customAttrs']}"
|
||||
style="
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: #002554;
|
||||
"
|
||||
th:styleappend="${'min-height: ' + (heroObj['height'] != null and !#strings.isEmpty(heroObj['height']) ? heroObj['height'] : '350px') + ';' + ((heroObj['bgImage'] != null and !#strings.isEmpty(heroObj['bgImage'])) ? 'background-image: url(' + heroObj['bgImage'] + ');' : 'background: linear-gradient(135deg, #002554 0%, #881C1C 100%);') + (heroObj['textAlign'] != null ? '; text-align: ' + heroObj['textAlign'] : '') + (heroObj['customStyle'] != null ? '; ' + heroObj['customStyle'] : '')}"
|
||||
>
|
||||
<div class="sis-hero-overlay"
|
||||
style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 1;"
|
||||
th:attr="data-type=${heroObj['gradientType'] != null ? heroObj['gradientType'] : 'dark_bottom'},
|
||||
data-c1=${heroObj['gradientColor1'] != null ? heroObj['gradientColor1'] : '#000000'},
|
||||
data-c2=${heroObj['gradientColor2'] != null ? heroObj['gradientColor2'] : '#1e3a5f'},
|
||||
data-opacity=${heroObj['overlayOpacity'] != null ? heroObj['overlayOpacity'] : '0.5'},
|
||||
data-angle=${heroObj['gradientAngle'] != null ? heroObj['gradientAngle'] : '160'},
|
||||
data-rx=${heroObj['gradientRadialX'] != null ? heroObj['gradientRadialX'] : '50'},
|
||||
data-ry=${heroObj['gradientRadialY'] != null ? heroObj['gradientRadialY'] : '50'}">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var overlay = document.currentScript.previousElementSibling;
|
||||
if (!overlay || !overlay.classList.contains('sis-hero-overlay')) return;
|
||||
function hex2rgba(hex, alpha) {
|
||||
hex = (hex || '#000000').replace('#', '');
|
||||
if (hex.length === 3) hex = hex.split('').map(function(c) { return c + c; }).join('');
|
||||
var r = parseInt(hex.substring(0, 2), 16) || 0;
|
||||
var g = parseInt(hex.substring(2, 4), 16) || 0;
|
||||
var b = parseInt(hex.substring(4, 6), 16) || 0;
|
||||
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
|
||||
}
|
||||
var type = overlay.getAttribute('data-type') || 'dark_bottom';
|
||||
var c1 = overlay.getAttribute('data-c1') || '#000000';
|
||||
var c2 = overlay.getAttribute('data-c2') || '#1e3a5f';
|
||||
var op = parseFloat(overlay.getAttribute('data-opacity') || '0.5');
|
||||
var angle = overlay.getAttribute('data-angle') || '160';
|
||||
var rx = overlay.getAttribute('data-rx') || '50';
|
||||
var ry = overlay.getAttribute('data-ry') || '50';
|
||||
|
||||
var grad = 'rgba(0,0,0,' + op + ')';
|
||||
if (type === 'none') { grad = 'rgba(0,0,0,' + op + ')'; }
|
||||
else if (type === 'linear') { grad = 'linear-gradient(' + angle + 'deg, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, 0) + ')'; }
|
||||
else if (type === 'linear_both' || type === 'custom_color') { grad = 'linear-gradient(' + angle + 'deg, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, op) + ')'; }
|
||||
else if (type === 'radial') { grad = 'radial-gradient(ellipse at ' + rx + '% ' + ry + '%, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, 0) + ')'; }
|
||||
else if (type === 'radial_both') { grad = 'radial-gradient(ellipse at ' + rx + '% ' + ry + '%, ' + hex2rgba(c1, op) + ', ' + hex2rgba(c2, op) + ')'; }
|
||||
else if (type === 'dark_bottom') { grad = 'linear-gradient(to top, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
|
||||
else if (type === 'dark_top') { grad = 'linear-gradient(to bottom, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
|
||||
else if (type === 'dark_left') { grad = 'linear-gradient(to right, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
|
||||
else if (type === 'dark_right') { grad = 'linear-gradient(to left, rgba(0,0,0,' + op + ') 0%, rgba(0,0,0,0) 60%)'; }
|
||||
else if (type === 'spotlight') { grad = 'radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0) 20%, rgba(0,0,0,' + op + ') 80%)'; }
|
||||
else if (type === 'vignette') { grad = 'radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0) 40%, rgba(0,0,0,' + op + ') 100%)'; }
|
||||
else if (type === 'duo_blue') { grad = 'linear-gradient(' + angle + 'deg, rgba(2,132,199,' + op + '), rgba(15,23,42,' + op + '))'; }
|
||||
else if (type === 'duo_purple') { grad = 'linear-gradient(' + angle + 'deg, rgba(126,34,206,' + op + '), rgba(15,23,42,' + op + '))'; }
|
||||
else if (type === 'duo_sunset') { grad = 'linear-gradient(' + angle + 'deg, rgba(220,38,38,' + op + '), rgba(249,115,22,' + op + '), rgba(234,179,8,' + op + '))'; }
|
||||
else if (type === 'duo_ocean') { grad = 'linear-gradient(' + angle + 'deg, rgba(6,182,212,' + op + '), rgba(37,99,235,' + op + '))'; }
|
||||
else if (type === 'duo_forest') { grad = 'linear-gradient(' + angle + 'deg, rgba(22,163,74,' + op + '), rgba(15,23,42,' + op + '))'; }
|
||||
else if (type === 'duo_rose') { grad = 'linear-gradient(' + angle + 'deg, rgba(244,63,94,' + op + '), rgba(251,191,36,' + op + '))'; }
|
||||
overlay.style.background = grad;
|
||||
})();
|
||||
</script>
|
||||
<div class="container" style="position: relative; z-index: 2">
|
||||
<h1 class="display-4 font-weight-bold text-white mb-3" th:if="${heroObj['title'] != null and !#strings.isEmpty(heroObj['title'])}" th:utext="${heroObj['title']}">Hero Banner Title</h1>
|
||||
<p class="lead text-light mb-4" th:if="${heroObj['subtitle'] != null and !#strings.isEmpty(heroObj['subtitle'])}" th:utext="${heroObj['subtitle']}"></p>
|
||||
<a th:if="${heroObj['btnText'] != null and !#strings.isEmpty(heroObj['btnText'])}" th:href="${(heroObj['btnLink'] != null and !#strings.isEmpty(heroObj['btnLink'])) ? heroObj['btnLink'] : '#'}" class="btn btn-danger btn-lg font-weight-bold px-4 py-2 shadow" th:utext="${heroObj['btnText']}">Click Here</a>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
|
||||
<!-- Nested Plugin: html-snippet -->
|
||||
<th:block th:if="${type == 'html-snippet' and ((cellObj != null and cellObj != '' and cellObj['id'] != null) or (blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['id'] != null))}">
|
||||
<div th:replace="~{fragments/snippets :: snippet(id=${cellObj != null and cellObj['id'] != null ? cellObj['id'] : blockData['col' + i]['id']})}"></div>
|
||||
@@ -1444,8 +1912,13 @@
|
||||
|
||||
<!-- Nested Plugin: cmsPlugin -->
|
||||
<th:block th:if="${type == 'cmsPlugin' or type == 'cms-plugin'}">
|
||||
<th:block th:with="shortcodeVal=${cellObj != null and cellObj['shortcode'] != null ? cellObj['shortcode'] : (cellObj != null and cellObj['id'] != null ? cellObj['id'] : (blockData != null and blockData['col' + i] != null and blockData['col' + i]['shortcode'] != null ? blockData['col' + i]['shortcode'] : (blockData != null and blockData['col' + i] != null ? blockData['col' + i]['id'] : '')))}">
|
||||
<div th:utext="${hookManager.applyFilters('the_content', shortcodeVal)}" th:remove="tag"></div>
|
||||
<th:block th:with="cmsObj=${cellObj != null ? cellObj : (blockData != null ? blockData['col' + i] : null)}, shortcodeVal=${cellObj != null and cellObj['shortcode'] != null ? cellObj['shortcode'] : (cellObj != null and cellObj['id'] != null ? cellObj['id'] : (blockData != null and blockData['col' + i] != null and blockData['col' + i]['shortcode'] != null ? blockData['col' + i]['shortcode'] : (blockData != null and blockData['col' + i] != null ? blockData['col' + i]['id'] : '')))}">
|
||||
<div th:classappend="${cmsObj != null and cmsObj['cssClass'] != null ? cmsObj['cssClass'] : ''}"
|
||||
th:id="${cmsObj != null and cmsObj['elementId'] != null ? cmsObj['elementId'] : ''}"
|
||||
th:styleappend="${cmsObj != null and cmsObj['customStyle'] != null ? cmsObj['customStyle'] : ''}"
|
||||
th:attr="data-custom-attrs=${cmsObj != null and cmsObj['customAttrs'] != null ? cmsObj['customAttrs'] : ''}">
|
||||
<div th:utext="${hookManager.applyFilters('the_content', shortcodeVal)}" th:remove="tag"></div>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
@@ -1524,6 +1997,38 @@
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- Nested Plugin: cards -->
|
||||
<th:block th:if="${(type == 'cards' or type == 'practiceCards' or type == 'practice-cards') and ((cellObj != null and cellObj != '' and cellObj['items'] != null) or (blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['items'] != null))}">
|
||||
<th:block th:with="cObj=${cellObj != null and cellObj['items'] != null ? cellObj : blockData['col' + i]}">
|
||||
<div th:id="${cObj['globalId'] != null and !#strings.isEmpty(cObj['globalId']) ? cObj['globalId'] : null}"
|
||||
th:class="${cObj['globalClass'] != null and !#strings.isEmpty(cObj['globalClass']) ? cObj['globalClass'] : 'sis-practice-card-section'}">
|
||||
<div class="container-fluid p-0">
|
||||
<div class="row g-4 align-items-stretch">
|
||||
<th:block th:each="item : ${cObj['items']}">
|
||||
<div th:if="${item['active'] == null or item['active'] == true}"
|
||||
th:class="${cObj['cols'] == 1 ? 'col-12 mb-4' : (cObj['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (cObj['cols'] == 3 ? 'col-lg-4 col-md-6 col-12 mb-4' : (cObj['cols'] == 6 ? 'col-lg-2 col-md-4 col-6 mb-4' : 'col-lg-3 col-md-6 col-12 mb-4')))}">
|
||||
<div class="practice-card">
|
||||
<div class="card-title-custom" th:utext="${item['title']}">Title</div>
|
||||
<div class="card-footer-custom">
|
||||
<span class="date-text" th:if="${item['date'] != null and !#strings.isEmpty(item['date'])}" th:text="${item['date']}">22/06/2026</span>
|
||||
<a th:href="${item['linkUrl'] != null and !#strings.isEmpty(item['linkUrl']) ? item['linkUrl'] : '#'}"
|
||||
th:target="${item['linkTarget'] != null ? item['linkTarget'] : '_self'}"
|
||||
class="action-link">
|
||||
<span th:text="${item['linkText'] != null and !#strings.isEmpty(item['linkText']) ? item['linkText'] : 'Tìm hiểu thêm'}">Tìm hiểu thêm</span>
|
||||
<div class="icon-circle">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- Nested Plugin: hovercard -->
|
||||
<th:block th:if="${(type == 'hovercard' or type == 'hover-card' or type == 'hoverCard') and ((cellObj != null and cellObj != '' and cellObj['items'] != null) or (blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['items'] != null))}">
|
||||
<th:block th:with="hcObj=${cellObj != null and cellObj['items'] != null ? cellObj : blockData['col' + i]}">
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
<html
|
||||
lang="vi"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{fragments/manage-layout}"
|
||||
>
|
||||
<head>
|
||||
<title th:text="${pageTitle} + ' - SIS Vietnam'">Quản lý Google Form Settings</title>
|
||||
<style>
|
||||
.shortcode-badge {
|
||||
font-family: monospace;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #c7d2fe;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.copy-btn {
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.copy-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
<div class="d-sm-flex align-items-center justify-content-between mb-4">
|
||||
<h1 class="h3 mb-0 text-gray-800" th:text="${pageTitle}">Quản lý Google Form & Form Tương Tác</h1>
|
||||
<div>
|
||||
<button type="button" class="btn btn-success font-weight-bold" onclick="openAddModal()">
|
||||
<i class="fas fa-plus-circle mr-1"></i> Thêm Form Mới
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Alerts -->
|
||||
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<span th:text="${successMessage}"></span>
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
</div>
|
||||
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<span th:text="${errorMessage}"></span>
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
</div>
|
||||
|
||||
<!-- Main Form -->
|
||||
<form method="post" th:action="@{/manage/plugins/google-form}" id="mainGoogleForm">
|
||||
<input type="hidden" name="formData" id="formData" />
|
||||
|
||||
<!-- Forms List View (Read-Only List with Modal Edit) -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center bg-white text-primary">
|
||||
<h6 class="m-0 font-weight-bold"><i class="fab fa-google mr-2"></i> Danh sách các Form trong hệ thống</h6>
|
||||
<button type="submit" class="btn btn-primary btn-sm font-weight-bold px-3" onclick="return saveAllForms();">
|
||||
<i class="fas fa-save mr-1"></i> Lưu Cấu Hình
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover mb-0" id="mainFormsList">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th style="width: 50px" class="text-center">STT</th>
|
||||
<th style="width: 140px">ID (Slug)</th>
|
||||
<th>Tên Form</th>
|
||||
<th style="width: 160px">Loại Form</th>
|
||||
<th style="width: 130px">Giao diện (Theme)</th>
|
||||
<th style="width: 320px">Shortcode nhúng</th>
|
||||
<th style="width: 160px" class="text-center">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="formsTableBody">
|
||||
<!-- Populated dynamically by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Instruction Alert -->
|
||||
<div class="card border-left-info shadow py-2 mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="font-weight-bold text-info"><i class="fas fa-info-circle mr-1"></i> Hướng dẫn sử dụng Google Form Shortcode</h6>
|
||||
<p class="small text-muted mb-0">
|
||||
Dùng shortcode <code class="shortcode-badge">[plugin:google-form id="tri-an"]</code> dán vào nội dung bài viết, trang tĩnh hoặc Editor.js HTML Snippet block để hiển thị Form tương ứng.<br/>
|
||||
Dữ liệu từ Google Form Embed được lưu trữ trực tiếp trên Google Sheets / Google Drive của bạn.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Form Modal -->
|
||||
<div class="modal fade" id="formModal" tabindex="-1" role="dialog" aria-labelledby="formModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title font-weight-bold" id="formModalLabel">Cấu hình Form</h5>
|
||||
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="modalFormIndex" value="-1" />
|
||||
<div class="row">
|
||||
<div class="col-md-6 form-group">
|
||||
<label class="font-weight-bold text-gray-700">Mã ID (Slug Shortcode) <span class="text-danger">*</span></label>
|
||||
<input type="text" id="modalId" class="form-control" placeholder="Ví dụ: tri-an, contact-1" />
|
||||
<small class="form-text text-muted">Mã duy nhất dùng trong shortcode <code>[plugin:google-form id="..."]</code></small>
|
||||
</div>
|
||||
<div class="col-md-6 form-group">
|
||||
<label class="font-weight-bold text-gray-700">Tên Quản lý Form <span class="text-danger">*</span></label>
|
||||
<input type="text" id="modalName" class="form-control" placeholder="Ví dụ: Form Tri Ân Bệnh Viện ĐHYD" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 form-group">
|
||||
<label class="font-weight-bold text-gray-700">Loại Form</label>
|
||||
<select id="modalFormType" class="form-control" onchange="toggleFormTypeFields()">
|
||||
<option value="tri_an">Form Chia Sẻ Lời Tri Ân (Tương tác có Upload ảnh)</option>
|
||||
<option value="contact">Form Liên Hệ & Góp Ý</option>
|
||||
<option value="google_iframe">Google Form (IFrame Embed Link)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 form-group">
|
||||
<label class="font-weight-bold text-gray-700">Giao diện (Theme)</label>
|
||||
<select id="modalTheme" class="form-control">
|
||||
<option value="blue_gradient">Xanh Dương Gradient (ĐHYD HCM style)</option>
|
||||
<option value="red_gradient">Đỏ Gradient (S.I.S style)</option>
|
||||
<option value="teal_gradient">Xanh Ngọc Gradient</option>
|
||||
<option value="light">Sáng Tối Giản (Minimal Light)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 form-group">
|
||||
<label class="font-weight-bold text-gray-700">Tiêu đề Form (Title)</label>
|
||||
<input type="text" id="modalTitle" class="form-control" placeholder="Ví dụ: Chia sẻ lời tri ân" />
|
||||
</div>
|
||||
<div class="col-md-6 form-group">
|
||||
<label class="font-weight-bold text-gray-700">Tiêu đề phụ (Subtitle)</label>
|
||||
<input type="text" id="modalSubtitle" class="form-control" placeholder="Ví dụ: Gửi lời cảm ơn chân thành..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Google Form Embed URL (shown when formType is google_iframe) -->
|
||||
<div id="googleIframeFields" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold text-primary"><i class="fab fa-google mr-1"></i> Google Form Embed URL <span class="text-danger">*</span></label>
|
||||
<input type="url" id="modalEmbedUrl" class="form-control" placeholder="https://docs.google.com/forms/d/e/.../viewform?embedded=true" />
|
||||
<small class="form-text text-muted">Lấy từ Google Form -> Gửi -> Nhúng HTML -> Sao chép URL trong thẻ <code>src="..."</code></small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold text-gray-700">Chiều cao iFrame (Height)</label>
|
||||
<input type="text" id="modalHeight" class="form-control" placeholder="650px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Form Settings -->
|
||||
<div id="customFormFields">
|
||||
<div class="row">
|
||||
<div class="col-md-6 form-group">
|
||||
<label class="font-weight-bold text-gray-700">Tên Nút Nộp (Button Text)</label>
|
||||
<input type="text" id="modalButtonText" class="form-control" placeholder="Gửi thông tin" />
|
||||
</div>
|
||||
<div class="col-md-6 form-group">
|
||||
<label class="font-weight-bold text-gray-700">Thông báo Thành Công (Success Alert)</label>
|
||||
<input type="text" id="modalSuccessMsg" class="form-control" placeholder="Gửi thông tin thành công!" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Hủy</button>
|
||||
<button type="button" class="btn btn-primary font-weight-bold" onclick="saveModalForm()">Lưu Form</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script th:inline="javascript">
|
||||
/*<![CDATA[*/
|
||||
var formsData = [];
|
||||
try {
|
||||
var rawJson = /*[[${formsJson}]]*/ '[]';
|
||||
formsData = JSON.parse(rawJson || '[]');
|
||||
} catch (e) {
|
||||
formsData = [];
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
renderFormsTable();
|
||||
});
|
||||
|
||||
function renderFormsTable() {
|
||||
var tbody = document.getElementById('formsTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (!formsData || formsData.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted">Chưa có Form nào. Bấm <strong>Thêm Form Mới</strong> để tạo.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
formsData.forEach(function (form, index) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var fTypeLabel = form.formType === 'google_iframe' ? '<span class="badge badge-danger">Google Form iFrame</span>' :
|
||||
form.formType === 'contact' ? '<span class="badge badge-info">Form Liên Hệ</span>' :
|
||||
'<span class="badge badge-success">Form Tri Ân</span>';
|
||||
|
||||
var themeLabel = form.theme === 'red_gradient' ? 'Đỏ S.I.S' :
|
||||
form.theme === 'teal_gradient' ? 'Xanh Ngọc' :
|
||||
form.theme === 'light' ? 'Sáng Minimal' : 'Xanh ĐHYD';
|
||||
|
||||
var shortcodeText = '[plugin:google-form id="' + (form.id || '') + '"]';
|
||||
|
||||
tr.innerHTML =
|
||||
'<td class="text-center align-middle font-weight-bold">' + (index + 1) + '</td>' +
|
||||
'<td class="align-middle font-weight-bold text-primary"><code>' + escapeHtml(form.id || '') + '</code></td>' +
|
||||
'<td class="align-middle font-weight-bold">' + escapeHtml(form.name || form.title || 'Form ' + (index+1)) + '</td>' +
|
||||
'<td class="align-middle">' + fTypeLabel + '</td>' +
|
||||
'<td class="align-middle"><small class="text-muted">' + themeLabel + '</small></td>' +
|
||||
'<td class="align-middle">' +
|
||||
'<div class="d-flex align-items-center justify-content-between">' +
|
||||
'<span class="shortcode-badge" id="sc-' + index + '">' + escapeHtml(shortcodeText) + '</span>' +
|
||||
'<button type="button" class="btn btn-outline-secondary btn-sm copy-btn ml-2" onclick="copyShortcode(\'' + escapeHtml(shortcodeText) + '\')"><i class="far fa-copy"></i></button>' +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'<td class="text-center align-middle">' +
|
||||
'<button type="button" class="btn btn-sm btn-info mr-1" onclick="openEditModal(' + index + ')"><i class="fas fa-edit"></i> Sửa</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger" onclick="deleteForm(' + index + ')"><i class="fas fa-trash"></i> Xóa</button>' +
|
||||
'</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleFormTypeFields() {
|
||||
var fType = document.getElementById('modalFormType').value;
|
||||
var iframeGroup = document.getElementById('googleIframeFields');
|
||||
var customGroup = document.getElementById('customFormFields');
|
||||
if (fType === 'google_iframe') {
|
||||
iframeGroup.style.display = 'block';
|
||||
} else {
|
||||
iframeGroup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
document.getElementById('modalFormIndex').value = '-1';
|
||||
document.getElementById('formModalLabel').textContent = 'Thêm Form Mới';
|
||||
document.getElementById('modalId').value = 'form-' + (formsData.length + 1);
|
||||
document.getElementById('modalName').value = '';
|
||||
document.getElementById('modalTitle').value = 'Chia sẻ lời tri ân';
|
||||
document.getElementById('modalSubtitle').value = '';
|
||||
document.getElementById('modalFormType').value = 'tri_an';
|
||||
document.getElementById('modalTheme').value = 'blue_gradient';
|
||||
document.getElementById('modalEmbedUrl').value = '';
|
||||
document.getElementById('modalHeight').value = '650px';
|
||||
document.getElementById('modalButtonText').value = 'Gửi thông tin';
|
||||
document.getElementById('modalSuccessMsg').value = 'Gửi thông tin thành công!';
|
||||
toggleFormTypeFields();
|
||||
$('#formModal').modal('show');
|
||||
}
|
||||
|
||||
function openEditModal(index) {
|
||||
var form = formsData[index];
|
||||
if (!form) return;
|
||||
document.getElementById('modalFormIndex').value = index;
|
||||
document.getElementById('formModalLabel').textContent = 'Chỉnh sửa Form: ' + (form.name || form.id);
|
||||
document.getElementById('modalId').value = form.id || '';
|
||||
document.getElementById('modalName').value = form.name || '';
|
||||
document.getElementById('modalTitle').value = form.title || '';
|
||||
document.getElementById('modalSubtitle').value = form.subtitle || '';
|
||||
document.getElementById('modalFormType').value = form.formType || 'tri_an';
|
||||
document.getElementById('modalTheme').value = form.theme || 'blue_gradient';
|
||||
document.getElementById('modalEmbedUrl').value = form.embedUrl || '';
|
||||
document.getElementById('modalHeight').value = form.height || '650px';
|
||||
document.getElementById('modalButtonText').value = form.buttonText || 'Gửi thông tin';
|
||||
document.getElementById('modalSuccessMsg').value = form.successMsg || 'Gửi thông tin thành công!';
|
||||
toggleFormTypeFields();
|
||||
$('#formModal').modal('show');
|
||||
}
|
||||
|
||||
function saveModalForm() {
|
||||
var index = parseInt(document.getElementById('modalFormIndex').value, 10);
|
||||
var id = document.getElementById('modalId').value.trim();
|
||||
var name = document.getElementById('modalName').value.trim();
|
||||
|
||||
if (!id) {
|
||||
alert('Vui lòng nhập Mã ID (Slug)');
|
||||
return;
|
||||
}
|
||||
if (!name) name = id;
|
||||
|
||||
var item = {
|
||||
id: id,
|
||||
name: name,
|
||||
title: document.getElementById('modalTitle').value.trim(),
|
||||
subtitle: document.getElementById('modalSubtitle').value.trim(),
|
||||
formType: document.getElementById('modalFormType').value,
|
||||
theme: document.getElementById('modalTheme').value,
|
||||
embedUrl: document.getElementById('modalEmbedUrl').value.trim(),
|
||||
height: document.getElementById('modalHeight').value.trim() || '650px',
|
||||
buttonText: document.getElementById('modalButtonText').value.trim() || 'Gửi thông tin',
|
||||
successMsg: document.getElementById('modalSuccessMsg').value.trim() || 'Gửi thông tin thành công!'
|
||||
};
|
||||
|
||||
if (index >= 0 && index < formsData.length) {
|
||||
formsData[index] = item;
|
||||
} else {
|
||||
formsData.push(item);
|
||||
}
|
||||
|
||||
renderFormsTable();
|
||||
$('#formModal').modal('hide');
|
||||
}
|
||||
|
||||
function deleteForm(index) {
|
||||
if (confirm('Bạn có chắc chắn muốn xóa Form này?')) {
|
||||
formsData.splice(index, 1);
|
||||
renderFormsTable();
|
||||
}
|
||||
}
|
||||
|
||||
function saveAllForms() {
|
||||
document.getElementById('formData').value = JSON.stringify(formsData);
|
||||
return true;
|
||||
}
|
||||
|
||||
function copyShortcode(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
alert('Đã sao chép shortcode: ' + text);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
/*]]>*/
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,7 +21,7 @@
|
||||
<th:block th:utext="${hookManager.doActionAndReturn('wp_head')}"></th:block>
|
||||
</head>
|
||||
<body
|
||||
th:class="${(bodyClass != null ? bodyClass : '') + ((page != null and page.pageType != null and page.pageType.name() == 'HOME') ? ' umass-platform-homepage path-frontpage page-node-type-homepage homepage transparent-header' : '')}"
|
||||
th:class="${(bodyClass != null ? bodyClass : '') + ((page != null and page.pageType != null and page.pageType.name() == 'HOME') ? ' umass-platform-homepage path-frontpage page-node-type-homepage homepage transparent-header' : '') + ((page != null and page.slug != null and !#strings.isEmpty(page.slug) and (bodyClass == null or !#strings.contains(bodyClass, page.slug))) ? (' ' + page.slug + ' page-' + page.slug + ' page-slug-' + page.slug) : '')}"
|
||||
>
|
||||
<div class="dialog-off-canvas-main-canvas" data-off-canvas-main-canvas="">
|
||||
<!-- Include Header Fragment -->
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
|
||||
<div class="gallery-stage" id="galleryStage">
|
||||
<article class="photo-card p1" data-index="0" style="--x: -4%; --y: 2%; --r: -2.5deg; --w: 205px; --delay: 0.05s">
|
||||
<img src="/upload/charity_v4/photo-01.jpg" alt="Trao hỗ trợ bệnh nhân 1" />
|
||||
<article class="photo-card p1" data-index="0" style="--x: -4; --y: 2; --r: -2.5deg; --w: 205px; --delay: 0.05s">
|
||||
<img src="/uploads/2026/08/photo-01.jpg" alt="Trao hỗ trợ bệnh nhân 1" />
|
||||
</article>
|
||||
<article class="photo-card p2" data-index="1" style="--x: 18%; --y: -2%; --r: 1.6deg; --w: 298px; --delay: 0.16s">
|
||||
<img src="/upload/charity_v4/photo-02.jpg" alt="Trao hỗ trợ bệnh nhân 2" />
|
||||
<article class="photo-card p2" data-index="1" style="--x: 18; --y: -2; --r: 1.6deg; --w: 298px; --delay: 0.16s">
|
||||
<img src="/uploads/2026/08/photo-02.jpg" alt="Trao hỗ trợ bệnh nhân 2" />
|
||||
</article>
|
||||
<article class="photo-card p3" data-index="2" style="--x: 63%; --y: 0%; --r: -1.2deg; --w: 410px; --delay: 0.27s">
|
||||
<article class="photo-card p3" data-index="2" style="--x: 63; --y: 0; --r: -1.2deg; --w: 410px; --delay: 0.27s">
|
||||
<img src="/upload/charity_v4/photo-05.jpg" alt="Trao hỗ trợ bệnh nhân 3" />
|
||||
</article>
|
||||
<article class="photo-card p4" data-index="3" style="--x: 0%; --y: 27%; --r: 2.2deg; --w: 245px; --delay: 0.38s">
|
||||
<img src="/upload/charity_v4/photo-03.jpg" alt="Trao hỗ trợ bệnh nhân 4" />
|
||||
<article class="photo-card p4" data-index="3" style="--x: 0; --y: 27; --r: 2.2deg; --w: 245px; --delay: 0.38s">
|
||||
<img src="/uploads/2026/08/photo-03.jpg" alt="Trao hỗ trợ bệnh nhân 4" />
|
||||
</article>
|
||||
<article class="photo-card p5" data-index="4" style="--x: 32%; --y: 32%; --r: -1.8deg; --w: 260px; --delay: 0.49s">
|
||||
<img src="/upload/charity_v4/photo-04.jpg" alt="Trao hỗ trợ bệnh nhân 5" />
|
||||
<article class="photo-card p5" data-index="4" style="--x: 32; --y: 32; --r: -1.8deg; --w: 260px; --delay: 0.49s">
|
||||
<img src="/uploads/2026/08/photo-04.jpg" alt="Trao hỗ trợ bệnh nhân 5" />
|
||||
</article>
|
||||
<article class="photo-card p6" data-index="5" style="--x: 57%; --y: 35%; --r: 1deg; --w: 195px; --delay: 0.6s">
|
||||
<img src="/upload/charity_v4/photo-06.jpg" alt="Trao hỗ trợ bệnh nhân 6" />
|
||||
<article class="photo-card p6" data-index="5" style="--x: 57; --y: 35; --r: 1deg; --w: 195px; --delay: 0.6s">
|
||||
<img src="/uploads/2026/08/photo-06.jpg" alt="Trao hỗ trợ bệnh nhân 6" />
|
||||
</article>
|
||||
<article class="photo-card p7" data-index="6" style="--x: 4%; --y: 62%; --r: -1.4deg; --w: 270px; --delay: 0.71s">
|
||||
<img src="/upload/charity_v4/photo-07.jpg" alt="Trao hỗ trợ bệnh nhân 7" />
|
||||
<article class="photo-card p7" data-index="6" style="--x: 4; --y: 62; --r: -1.4deg; --w: 270px; --delay: 0.71s">
|
||||
<img src="/uploads/2026/08/photo-07.jpg" alt="Trao hỗ trợ bệnh nhân 7" />
|
||||
</article>
|
||||
<article class="photo-card p8" data-index="7" style="--x: 42%; --y: 73%; --r: 1.2deg; --w: 255px; --delay: 0.82s">
|
||||
<img src="/upload/charity_v4/photo-08.jpg" alt="Trao hỗ trợ bệnh nhân 8" />
|
||||
<article class="photo-card p8" data-index="7" style="--x: 42; --y: 73; --r: 1.2deg; --w: 255px; --delay: 0.82s">
|
||||
<img src="/uploads/2026/08/photo-08.jpg" alt="Trao hỗ trợ bệnh nhân 8" />
|
||||
</article>
|
||||
<article class="photo-card p9" data-index="8" style="--x: 77%; --y: 66%; --r: -1deg; --w: 338px; --delay: 0.93s">
|
||||
<img src="/upload/charity_v4/photo-09.jpg" alt="Trao hỗ trợ bệnh nhân 9" />
|
||||
<article class="photo-card p9" data-index="8" style="--x: 77; --y: 66; --r: -1deg; --w: 338px; --delay: 0.93s">
|
||||
<img src="/uploads/2026/08/photo-09.jpg" alt="Trao hỗ trợ bệnh nhân 9" />
|
||||
</article>
|
||||
<div class="gallery-light" aria-hidden="true"></div>
|
||||
</div>
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.30.8/dist/editorjs.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js"></script>
|
||||
<style>
|
||||
.ce-block { margin: 20px; border: 1px solid #ccc; padding: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editorjs"></div>
|
||||
<script>
|
||||
class TinyMCETool {
|
||||
static get toolbox() { return { title: 'TinyMCE', icon: 'T' }; }
|
||||
render() {
|
||||
const container = document.createElement('div');
|
||||
const textarea = document.createElement('textarea');
|
||||
container.appendChild(textarea);
|
||||
setTimeout(() => {
|
||||
tinymce.init({
|
||||
target: textarea,
|
||||
setup: function(editor) {
|
||||
editor.on('focusin mousedown click keyup', function() {
|
||||
container.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
||||
container.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true }));
|
||||
console.log('Dispatched mousedown & mousemove');
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
return container;
|
||||
}
|
||||
save() { return { html: '' }; }
|
||||
}
|
||||
const editor = new EditorJS({
|
||||
holder: 'editorjs',
|
||||
tools: { tiny: TinyMCETool },
|
||||
data: {
|
||||
blocks: [
|
||||
{ type: 'paragraph', data: { text: 'Block 1' } },
|
||||
{ type: 'tiny', data: {} },
|
||||
{ type: 'paragraph', data: { text: 'Block 3' } }
|
||||
]
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.30.8/dist/editorjs.umd.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editorjs"></div>
|
||||
<script>
|
||||
class CustomTool {
|
||||
static get toolbox() { return { title: 'Custom', icon: 'C' }; }
|
||||
constructor({api}) { this.api = api; }
|
||||
render() {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = '<input type="text" placeholder="Click me">';
|
||||
div.addEventListener('click', () => {
|
||||
const count = this.api.blocks.getBlocksCount();
|
||||
for(let i=0; i<count; i++) {
|
||||
const b = this.api.blocks.getBlockByIndex(i);
|
||||
if (b.holder.contains(div)) {
|
||||
console.log("Setting to block", i);
|
||||
this.api.caret.setToBlock(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
return div;
|
||||
}
|
||||
save() { return {}; }
|
||||
}
|
||||
const editor = new EditorJS({
|
||||
holder: 'editorjs',
|
||||
tools: { custom: CustomTool },
|
||||
data: { blocks: [ { type: 'paragraph', data: { text: 'Block 1' } }, { type: 'custom', data: {} } ] }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="wrapper">
|
||||
<textarea id="mytext"></textarea>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js"></script>
|
||||
<script>
|
||||
document.getElementById('wrapper').addEventListener('mousedown', function(e) {
|
||||
console.log('wrapper mousedown received!', e.isTrusted, e.clientX, e.clientY);
|
||||
});
|
||||
tinymce.init({
|
||||
selector: '#mytext',
|
||||
setup: function(editor) {
|
||||
editor.on('focusin mousedown click keyup', function(e) {
|
||||
console.log('tinymce event:', e.type);
|
||||
const target = document.getElementById('wrapper');
|
||||
target.dispatchEvent(new MouseEvent('mousedown', {
|
||||
bubbles: true, cancelable: true, view: window, clientX: 10, clientY: 10
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 224 B |