feat: implement swiper slider, tabbed livestream, and table extension plugins with schema updates for settings content
@@ -1,28 +1,26 @@
|
||||
package com.sisvietnamvn.web.config;
|
||||
|
||||
import static org.springframework.security.config.Customizer.withDefaults;
|
||||
|
||||
import com.sisvietnamvn.web.security.*;
|
||||
import com.sisvietnamvn.web.security.oauth2.CustomOidcUserService;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import static org.springframework.security.config.Customizer.withDefaults;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.password.MessageDigestPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
|
||||
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import tech.jhipster.config.JHipsterConstants;
|
||||
import tech.jhipster.config.JHipsterProperties;
|
||||
|
||||
import com.sisvietnamvn.web.security.CustomAuthenticationSuccessHandler;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
import com.sisvietnamvn.web.security.AuthoritiesConstants;
|
||||
import com.sisvietnamvn.web.security.CustomAuthenticationSuccessHandler;
|
||||
import com.sisvietnamvn.web.security.oauth2.CustomOidcUserService;
|
||||
|
||||
import tech.jhipster.config.JHipsterConstants;
|
||||
import tech.jhipster.config.JHipsterProperties;
|
||||
|
||||
@Configuration
|
||||
@EnableMethodSecurity(securedEnabled = true)
|
||||
@@ -57,7 +55,8 @@ public class SecurityConfiguration {
|
||||
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
|
||||
"/manage/login", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**", "/UMass*/**", "/Undergraduate*/**",
|
||||
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/upload/**", "/api/manage/snippets/**", "/page/**", "/news/article/**", "/post/**", "/error",
|
||||
"/about-us", "/specialty", "/doctor", "/bac-si/**", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**")
|
||||
"/about-us", "/specialty", "/doctor", "/bac-si/**", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**", "/dao-tao/**",
|
||||
"/Đào tạo tại UMC_files/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/manage/media/upload").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.util.Map;
|
||||
/**
|
||||
* Provides global model attributes for all frontend controllers.
|
||||
*/
|
||||
@ControllerAdvice(basePackages = "com.sisvietnamvn.web.controller")
|
||||
@ControllerAdvice(basePackages = {"com.sisvietnamvn.web.controller", "com.sisvietnamvn.web.plugins"})
|
||||
public class GlobalControllerAdvice {
|
||||
|
||||
private final SettingService settingService;
|
||||
|
||||
@@ -80,6 +80,11 @@ public class PageController {
|
||||
@GetMapping("/about-us")
|
||||
public String getAboutUs(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.ABOUT_US), model); }
|
||||
|
||||
@GetMapping("/dao-tao")
|
||||
public String getDaoTao(Model model) {
|
||||
return "dao-tao";
|
||||
}
|
||||
|
||||
@GetMapping("/specialty")
|
||||
public String getSpecialty(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SPECIALTY), model); }
|
||||
|
||||
@@ -89,10 +94,10 @@ public class PageController {
|
||||
try {
|
||||
java.util.List<com.sisvietnamvn.web.domain.Doctor> doctors = doctorService.findAll();
|
||||
|
||||
// Filter active only
|
||||
doctors = doctors.stream()
|
||||
.filter(d -> Boolean.TRUE.equals(d.getActive()))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
// Show all doctors regardless of active status (so inactive still show up)
|
||||
// doctors = doctors.stream()
|
||||
// .filter(d -> Boolean.TRUE.equals(d.getActive()))
|
||||
// .collect(java.util.stream.Collectors.toList());
|
||||
|
||||
// Sort: 00001 and 00002 at the top
|
||||
doctors.sort((d1, d2) -> {
|
||||
@@ -127,8 +132,8 @@ public class PageController {
|
||||
public String getDoctorDetail(@PathVariable String doctorCode, Model model) {
|
||||
java.util.Optional<com.sisvietnamvn.web.domain.Doctor> doctorOpt = doctorService.findByDoctorCode(doctorCode);
|
||||
|
||||
if (doctorOpt.isEmpty() || !Boolean.TRUE.equals(doctorOpt.get().getActive())) {
|
||||
return "error/404";
|
||||
if (doctorOpt.isEmpty()) {
|
||||
return "redirect:/bac-si";
|
||||
}
|
||||
|
||||
com.sisvietnamvn.web.domain.Doctor doctor = doctorOpt.get();
|
||||
@@ -137,6 +142,10 @@ public class PageController {
|
||||
model.addAttribute("doctor", doctor);
|
||||
model.addAttribute("specialtyName", docSpecialty);
|
||||
|
||||
// Apply doctor intro hook based on specialty
|
||||
String doctorIntroHtml = hookManager.applyFilters("doctor_intro_by_specialty", "", docSpecialty);
|
||||
model.addAttribute("doctorIntroHtml", doctorIntroHtml);
|
||||
|
||||
List<Map<String, Object>> scheduleDays = doctorApiSyncService.getDoctorSchedule(doctorCode);
|
||||
model.addAttribute("scheduleDays", scheduleDays);
|
||||
|
||||
|
||||
@@ -32,10 +32,10 @@ public class ManageSpecialtyController {
|
||||
public String listSpecialties(
|
||||
@RequestParam(value = "search", required = false) String search,
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "10") int size,
|
||||
@RequestParam(value = "size", defaultValue = "1000") int size,
|
||||
Model model) {
|
||||
LOG.debug("Request to list all specialties");
|
||||
populateModel(model, new Specialty(), true, search, org.springframework.data.domain.PageRequest.of(page, size));
|
||||
populateModel(model, new Specialty(), true, search, org.springframework.data.domain.PageRequest.of(0, 1000));
|
||||
return "manage/specialties/index";
|
||||
}
|
||||
|
||||
@@ -44,12 +44,12 @@ public class ManageSpecialtyController {
|
||||
BindingResult bindingResult,
|
||||
@RequestParam(value = "search", required = false) String search,
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "10") int size,
|
||||
@RequestParam(value = "size", defaultValue = "1000") int size,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to create Specialty : {}", specialty);
|
||||
if (bindingResult.hasErrors()) {
|
||||
populateModel(model, specialty, true, search, org.springframework.data.domain.PageRequest.of(page, size));
|
||||
populateModel(model, specialty, true, search, org.springframework.data.domain.PageRequest.of(0, 1000));
|
||||
return "manage/specialties/index";
|
||||
}
|
||||
specialtyService.save(specialty);
|
||||
@@ -61,7 +61,7 @@ public class ManageSpecialtyController {
|
||||
public String showEditForm(@PathVariable Long id,
|
||||
@RequestParam(value = "search", required = false) String search,
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "10") int size,
|
||||
@RequestParam(value = "size", defaultValue = "1000") int size,
|
||||
Model model, RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to show edit form for Specialty : {}", id);
|
||||
Optional<Specialty> specialtyOptional = specialtyService.findById(id);
|
||||
@@ -69,7 +69,7 @@ public class ManageSpecialtyController {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Specialty not found.");
|
||||
return "redirect:/manage/specialties";
|
||||
}
|
||||
populateModel(model, specialtyOptional.get(), false, search, org.springframework.data.domain.PageRequest.of(page, size));
|
||||
populateModel(model, specialtyOptional.get(), false, search, org.springframework.data.domain.PageRequest.of(0, 1000));
|
||||
return "manage/specialties/index";
|
||||
}
|
||||
|
||||
@@ -79,12 +79,12 @@ public class ManageSpecialtyController {
|
||||
BindingResult bindingResult,
|
||||
@RequestParam(value = "search", required = false) String search,
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "10") int size,
|
||||
@RequestParam(value = "size", defaultValue = "1000") int size,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to update Specialty : {}", id);
|
||||
if (bindingResult.hasErrors()) {
|
||||
populateModel(model, specialty, false, search, org.springframework.data.domain.PageRequest.of(page, size));
|
||||
populateModel(model, specialty, false, search, org.springframework.data.domain.PageRequest.of(0, 1000));
|
||||
return "manage/specialties/index";
|
||||
}
|
||||
specialty.setId(id);
|
||||
|
||||
@@ -29,8 +29,7 @@ public class Setting extends AbstractAuditingEntity<Long> implements Serializabl
|
||||
@Column(name = "setting_key", length = 100, nullable = false, unique = true)
|
||||
private String settingKey;
|
||||
|
||||
@Size(max = 2000)
|
||||
@Column(name = "setting_value", length = 2000)
|
||||
@Column(name = "setting_value", columnDefinition = "TEXT")
|
||||
private String settingValue;
|
||||
|
||||
// jhipster-needle-entity-add-field - JHipster will add fields here
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.sisvietnamvn.web.plugins.doctorintro;
|
||||
|
||||
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 com.sisvietnamvn.web.hook.HookManager;
|
||||
|
||||
@Component
|
||||
public class DoctorIntroPlugin {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(DoctorIntroPlugin.class);
|
||||
private final HookManager hookManager;
|
||||
|
||||
public DoctorIntroPlugin(HookManager hookManager) {
|
||||
this.hookManager = hookManager;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onApplicationReady() {
|
||||
LOG.info("Registering DoctorIntroPlugin hooks...");
|
||||
|
||||
hookManager.addFilter("doctor_intro_by_specialty", (content, args) -> {
|
||||
if (args == null || args.length == 0 || args[0] == null) {
|
||||
return content; // Fallback
|
||||
}
|
||||
String specialtyName = args[0].toString().trim().toLowerCase();
|
||||
String intro = getIntroForSpecialty(specialtyName);
|
||||
|
||||
if (intro.isEmpty()) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return "<p class=\"body-3 text-gray-700 mt-3 mb-4\">" + intro + "</p>";
|
||||
}, 10);
|
||||
}
|
||||
|
||||
private String getIntroForSpecialty(String specialty) {
|
||||
if (specialty.contains("nội tổng hợp") || specialty.contains("nội th")) {
|
||||
return "Bác sĩ giàu kinh nghiệm trong thăm khám và điều trị các bệnh lý nội khoa, tư vấn tận tình và theo dõi sức khỏe toàn diện cho bệnh nhân.";
|
||||
}
|
||||
if (specialty.contains("ngoại tổng hợp") || specialty.contains("ngoại th")) {
|
||||
return "Bác sĩ chuyên khoa ngoại với tay nghề phẫu thuật cao, chuyên chẩn đoán và điều trị can thiệp các bệnh lý ngoại khoa, đảm bảo an toàn và phục hồi nhanh chóng.";
|
||||
}
|
||||
if (specialty.contains("tim mạch")) {
|
||||
return "Chuyên gia hàng đầu trong chẩn đoán và điều trị các bệnh lý tim mạch, cao huyết áp. Tận tâm bảo vệ nhịp đập khỏe mạnh cho trái tim của bạn.";
|
||||
}
|
||||
if (specialty.contains("thần kinh") || specialty.contains("đột quỵ") || specialty.contains("tk[đq")) {
|
||||
return "Bác sĩ chuyên sâu về các bệnh lý hệ thần kinh, đặc biệt có nhiều năm kinh nghiệm trong tầm soát, cấp cứu và điều trị di chứng đột quỵ.";
|
||||
}
|
||||
if (specialty.contains("tiêu hóa")) {
|
||||
return "Bác sĩ chuyên chẩn đoán và điều trị các bệnh lý dạ dày, đại tràng, gan mật; ứng dụng kỹ thuật nội soi hiện đại giúp phát hiện sớm các tổn thương.";
|
||||
}
|
||||
if (specialty.contains("nhi")) {
|
||||
return "Bác sĩ Nhi khoa tận tâm, yêu trẻ, chuyên khám chữa bệnh và tư vấn dinh dưỡng, phát triển thể chất toàn diện cho trẻ sơ sinh và trẻ nhỏ.";
|
||||
}
|
||||
if (specialty.contains("hồi sức") || specialty.contains("cấp cứu") || specialty.contains("icu")) {
|
||||
return "Bác sĩ cấp cứu phản ứng nhanh, chuyên môn vững vàng trong các tình huống nguy kịch, luôn sẵn sàng giành giật sự sống cho bệnh nhân từng giây từng phút.";
|
||||
}
|
||||
if (specialty.contains("gây mê")) {
|
||||
return "Bác sĩ gây mê hồi sức giàu kinh nghiệm, đảm bảo an toàn tuyệt đối, kiểm soát đau hiệu quả trước, trong và sau các ca phẫu thuật.";
|
||||
}
|
||||
if (specialty.contains("vật lý trị liệu") || specialty.contains("phục hồi chức năng")) {
|
||||
return "Bác sĩ chuyên khoa phục hồi chức năng, đồng hành cùng bệnh nhân trong quá trình tập luyện phục hồi vận động sau chấn thương và phẫu thuật.";
|
||||
}
|
||||
if (specialty.contains("dinh dưỡng")) {
|
||||
return "Chuyên gia tư vấn dinh dưỡng thiết kế thực đơn cá nhân hóa, hỗ trợ điều trị bệnh lý và cải thiện sức khỏe tổng thể thông qua chế độ ăn uống khoa học.";
|
||||
}
|
||||
if (specialty.contains("khám bệnh")) {
|
||||
return "Bác sĩ đa khoa giàu kinh nghiệm, chẩn đoán bệnh chính xác, tư vấn sức khỏe ban đầu và hướng dẫn tầm soát bệnh lý định kỳ hiệu quả.";
|
||||
}
|
||||
if (specialty.contains("sản") || specialty.contains("phụ sản")) {
|
||||
return "Bác sĩ chuyên khoa Phụ Sản tận tâm, đồng hành cùng mẹ bầu trong suốt thai kỳ và chuyên điều trị các bệnh lý phụ khoa, bảo vệ sức khỏe sinh sản phụ nữ.";
|
||||
}
|
||||
return "Bác sĩ giàu kinh nghiệm trong thăm khám và điều trị, tư vấn tận tâm và luôn đặt sức khỏe của bệnh nhân lên hàng đầu.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.sisvietnamvn.web.plugins.swiperslider;
|
||||
|
||||
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/swiper-slider")
|
||||
public class SwiperSliderAdminController {
|
||||
|
||||
private final SettingService settingService;
|
||||
private final AdminContext adminContext;
|
||||
private final ObjectMapper objectMapper;
|
||||
private static final String SETTING_KEY = "plugin_swiper_slider_data";
|
||||
|
||||
public SwiperSliderAdminController(SettingService settingService, AdminContext adminContext, ObjectMapper objectMapper) {
|
||||
this.settingService = settingService;
|
||||
this.adminContext = adminContext;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
private List<SliderGroup> getAllGroups() {
|
||||
String json = settingService.getValue(SETTING_KEY, "[]");
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (JsonProcessingException e) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private void saveAllGroups(List<SliderGroup> groups) {
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(groups);
|
||||
settingService.setValue(SETTING_KEY, json);
|
||||
} catch (JsonProcessingException e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String list(Model model) {
|
||||
if (!adminContext.currentUserCan("manage_options")) {
|
||||
return "error/403";
|
||||
}
|
||||
|
||||
List<SliderGroup> groups = getAllGroups();
|
||||
model.addAttribute("groups", groups);
|
||||
model.addAttribute("pageTitle", "Quản lý Swiper Sliders");
|
||||
|
||||
return "plugins/swiper-slider/admin-list";
|
||||
}
|
||||
|
||||
@GetMapping("/create")
|
||||
public String create(Model model) {
|
||||
if (!adminContext.currentUserCan("manage_options")) {
|
||||
return "error/403";
|
||||
}
|
||||
|
||||
model.addAttribute("groupJson", "{\"slug\":\"\", \"name\":\"\", \"layoutType\":\"\", \"outerTemplate\":\"\", \"itemTemplate\":\"\", \"items\":[]}");
|
||||
model.addAttribute("isNew", true);
|
||||
model.addAttribute("pageTitle", "Thêm Nhóm Slider Mới");
|
||||
|
||||
return "plugins/swiper-slider/admin-form";
|
||||
}
|
||||
|
||||
@GetMapping("/edit/{slug}")
|
||||
public String edit(@PathVariable String slug, Model model) {
|
||||
if (!adminContext.currentUserCan("manage_options")) {
|
||||
return "error/403";
|
||||
}
|
||||
|
||||
List<SliderGroup> groups = getAllGroups();
|
||||
SliderGroup group = groups.stream().filter(g -> slug.equals(g.slug())).findFirst().orElse(null);
|
||||
if (group == null) {
|
||||
return "redirect:/manage/plugins/swiper-slider";
|
||||
}
|
||||
|
||||
try {
|
||||
model.addAttribute("groupJson", objectMapper.writeValueAsString(group));
|
||||
} catch (JsonProcessingException e) {
|
||||
model.addAttribute("groupJson", "{}");
|
||||
}
|
||||
model.addAttribute("oldSlug", slug);
|
||||
model.addAttribute("isNew", false);
|
||||
model.addAttribute("pageTitle", "Sửa Nhóm Slider: " + group.name());
|
||||
|
||||
return "plugins/swiper-slider/admin-form";
|
||||
}
|
||||
|
||||
@PostMapping("/save")
|
||||
public String save(@RequestParam("groupData") String groupDataJson,
|
||||
@RequestParam(value = "oldSlug", required = false) String oldSlug,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
if (!adminContext.currentUserCan("manage_options")) {
|
||||
return "error/403";
|
||||
}
|
||||
|
||||
try {
|
||||
SliderGroup newGroup = objectMapper.readValue(groupDataJson, SliderGroup.class);
|
||||
List<SliderGroup> groups = getAllGroups();
|
||||
|
||||
if (oldSlug != null && !oldSlug.trim().isEmpty()) {
|
||||
// Update existing
|
||||
for (int i = 0; i < groups.size(); i++) {
|
||||
if (oldSlug.equals(groups.get(i).slug())) {
|
||||
groups.set(i, newGroup);
|
||||
break;
|
||||
}
|
||||
}
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Đã cập nhật cấu hình thành công!");
|
||||
} else {
|
||||
// Check duplicate slug
|
||||
boolean exists = groups.stream().anyMatch(g -> newGroup.slug().equals(g.slug()));
|
||||
if (exists) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Slug đã tồn tại, vui lòng chọn slug khác!");
|
||||
return "redirect:/manage/plugins/swiper-slider/create";
|
||||
}
|
||||
// Add new
|
||||
groups.add(newGroup);
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Đã thêm nhóm mới thành công!");
|
||||
}
|
||||
|
||||
saveAllGroups(groups);
|
||||
} catch (JsonProcessingException e) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Dữ liệu không hợp lệ!");
|
||||
}
|
||||
|
||||
return "redirect:/manage/plugins/swiper-slider";
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
public String delete(@RequestParam("slug") String slug, RedirectAttributes redirectAttributes) {
|
||||
if (!adminContext.currentUserCan("manage_options")) {
|
||||
return "error/403";
|
||||
}
|
||||
|
||||
List<SliderGroup> groups = getAllGroups();
|
||||
groups.removeIf(g -> slug.equals(g.slug()));
|
||||
saveAllGroups(groups);
|
||||
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Đã xóa nhóm thành công!");
|
||||
return "redirect:/manage/plugins/swiper-slider";
|
||||
}
|
||||
|
||||
public record SliderGroup(String slug, String name, String layoutType, String outerTemplate, String itemTemplate, List<SliderItem> items) {
|
||||
public String getSlug() { return slug; }
|
||||
public String getName() { return name; }
|
||||
public String getLayoutType() { return layoutType; }
|
||||
public List<SliderItem> getItems() { return items; }
|
||||
}
|
||||
public record SliderItem(String title, String imageUrl, String linkUrl, String description) {}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package com.sisvietnamvn.web.plugins.swiperslider;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.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.PluginRepository;
|
||||
import com.sisvietnamvn.web.service.SettingService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
public class SwiperSliderPlugin {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SwiperSliderPlugin.class);
|
||||
private static final String PLUGIN_KEY = "swiper-slider-plugin";
|
||||
private static final String SETTING_KEY = "plugin_swiper_slider_data";
|
||||
|
||||
private final PluginRepository pluginRepository;
|
||||
private final ComponentTemplateRepository templateRepository;
|
||||
private final HookManager hookManager;
|
||||
private final SettingService settingService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SwiperSliderPlugin(PluginRepository pluginRepository,
|
||||
ComponentTemplateRepository templateRepository,
|
||||
HookManager hookManager,
|
||||
SettingService settingService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.pluginRepository = pluginRepository;
|
||||
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("Swiper Slider Component");
|
||||
newPlugin.setVersion("1.0");
|
||||
newPlugin.setStatus(PluginStatus.ACTIVE);
|
||||
newPlugin.setAuthor("Antigravity");
|
||||
newPlugin.setDescription("Plugin quản lý Swiper Sliders.");
|
||||
return pluginRepository.save(newPlugin);
|
||||
});
|
||||
isActive = p.getStatus() == PluginStatus.ACTIVE;
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Could not verify SwiperSliderPlugin status: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
LOG.info("SwiperSliderPlugin is inactive.");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.info("SwiperSliderPlugin is active. Seeding components and hooks...");
|
||||
|
||||
seedComponentTemplate();
|
||||
seedDefaultData();
|
||||
|
||||
// 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/swiper-slider\">Swiper Sliders</a>\n";
|
||||
return html;
|
||||
}, 10);
|
||||
|
||||
// Hook to replace shortcode
|
||||
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);
|
||||
|
||||
hookManager.addFilter("swiper_slider_items", (content, args) -> {
|
||||
String slug = null;
|
||||
if (args != null && args.length > 0 && args[0] != null) {
|
||||
slug = args[0].toString();
|
||||
} else if (content != null && !content.toString().trim().isEmpty()) {
|
||||
slug = content.toString().trim();
|
||||
}
|
||||
if (slug == null || slug.isEmpty()) {
|
||||
return content;
|
||||
}
|
||||
return generateSliderItemsOnly(slug);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
private String processShortcodes(String text) {
|
||||
if (!text.contains("[plugin:swiper-slider")) {
|
||||
return text;
|
||||
}
|
||||
|
||||
Pattern pattern = Pattern.compile("\\[plugin:swiper-slider slug=\"([^\"]+)\"\\]");
|
||||
Matcher matcher = pattern.matcher(text);
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
while (matcher.find()) {
|
||||
String slug = matcher.group(1);
|
||||
String html = generateSliderHtml(slug);
|
||||
matcher.appendReplacement(result, Matcher.quoteReplacement(html));
|
||||
}
|
||||
matcher.appendTail(result);
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private Optional<SwiperSliderAdminController.SliderGroup> findGroupOrSeed(String slug) {
|
||||
String json = settingService.getValue(SETTING_KEY, "[]");
|
||||
List<SwiperSliderAdminController.SliderGroup> groups = parseGroups(json);
|
||||
Optional<SwiperSliderAdminController.SliderGroup> groupOpt = groups.stream()
|
||||
.filter(g -> slug.equals(g.slug()))
|
||||
.findFirst();
|
||||
if (groupOpt.isEmpty() || groupOpt.get().items() == null || groupOpt.get().items().isEmpty() || json.contains("UMC_files")) {
|
||||
json = forceSeedAllData();
|
||||
groups = parseGroups(json);
|
||||
groupOpt = groups.stream()
|
||||
.filter(g -> slug.equals(g.slug()))
|
||||
.findFirst();
|
||||
}
|
||||
return groupOpt;
|
||||
}
|
||||
|
||||
private String forceSeedAllData() {
|
||||
String sampleData = "[{\"slug\":\"gallery\",\"name\":\"Gallery Đào Tạo\",\"items\":[" +
|
||||
"{\"title\":\"Khóa đào tạo 1\",\"imageUrl\":\"/images/dao-tao/image-6.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Khóa đào tạo 2\",\"imageUrl\":\"/images/dao-tao/image-5.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Khóa đào tạo 3\",\"imageUrl\":\"/images/dao-tao/image-4.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
||||
"]},{\"slug\":\"chung-chi\",\"name\":\"Cấp chứng chỉ\",\"items\":[" +
|
||||
"{\"title\":\"Lớp chứng chỉ 1\",\"imageUrl\":\"/images/dao-tao/image-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Lớp chứng chỉ 2\",\"imageUrl\":\"/images/dao-tao/image-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Lớp chứng chỉ 3\",\"imageUrl\":\"/images/dao-tao/image-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
||||
"]},{\"slug\":\"giay-chung-nhan\",\"name\":\"Cấp giấy chứng nhận\",\"items\":[" +
|
||||
"{\"title\":\"Giấy chứng nhận 1\",\"imageUrl\":\"/images/dao-tao/training-4.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Giấy chứng nhận 2\",\"imageUrl\":\"/images/dao-tao/training-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
||||
"]},{\"slug\":\"xac-nhan-thuc-hanh\",\"name\":\"Đào tạo cấp giấy xác nhận quá trình thực hành\",\"items\":[" +
|
||||
"{\"title\":\"Xác nhận thực hành 1\",\"imageUrl\":\"/images/dao-tao/PTNS2707.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Xác nhận thực hành 2\",\"imageUrl\":\"/images/dao-tao/training-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
||||
"]},{\"slug\":\"hinh-anh-thuc-hanh\",\"name\":\"Hình ảnh thực hành lâm sàng\",\"items\":[" +
|
||||
"{\"title\":\"Thực hành lâm sàng 1\",\"imageUrl\":\"/images/dao-tao/training-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Thực hành lâm sàng 2\",\"imageUrl\":\"/images/dao-tao/training-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
||||
"]}]";
|
||||
settingService.setValue(SETTING_KEY, sampleData);
|
||||
LOG.info("Forced seeding of default swiper slider data for all 5 training sliders");
|
||||
return sampleData;
|
||||
}
|
||||
|
||||
private List<SwiperSliderAdminController.SliderGroup> parseGroups(String json) {
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<List<SwiperSliderAdminController.SliderGroup>>() {});
|
||||
} catch (Exception e) {
|
||||
LOG.error("Error parsing slider groups JSON: {}", e.getMessage(), e);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private String generateSliderHtml(String slug) {
|
||||
Optional<SwiperSliderAdminController.SliderGroup> groupOpt = findGroupOrSeed(slug);
|
||||
if (groupOpt.isEmpty()) {
|
||||
return "<!-- Slider group '" + slug + "' not found -->";
|
||||
}
|
||||
|
||||
SwiperSliderAdminController.SliderGroup group = groupOpt.get();
|
||||
if (group.items() == null || group.items().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Get Template
|
||||
String layoutType = group.layoutType();
|
||||
String template = group.outerTemplate();
|
||||
if (!"custom".equals(layoutType) || template == null || template.trim().isEmpty()) {
|
||||
Optional<com.sisvietnamvn.web.domain.ComponentTemplate> optTpl = templateRepository.findBySlug("swiper-slider");
|
||||
template = optTpl.map(com.sisvietnamvn.web.domain.ComponentTemplate::getHtmlTemplate).orElse(getDefaultTemplate());
|
||||
}
|
||||
|
||||
String slidesHtml = generateSliderItemsOnly(slug);
|
||||
|
||||
String finalHtml = template.replaceAll("(?s)\\{\\{#each items\\}\\}.*?\\{\\{/each\\}\\}", Matcher.quoteReplacement(slidesHtml));
|
||||
finalHtml = finalHtml.replace("{{slug}}", slug);
|
||||
return finalHtml;
|
||||
}
|
||||
|
||||
private String generateSliderItemsOnly(String slug) {
|
||||
Optional<SwiperSliderAdminController.SliderGroup> groupOpt = findGroupOrSeed(slug);
|
||||
if (groupOpt.isEmpty()) {
|
||||
return "<!-- Slider group '" + slug + "' not found -->";
|
||||
}
|
||||
|
||||
SwiperSliderAdminController.SliderGroup group = groupOpt.get();
|
||||
if (group.items() == null || group.items().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder slidesHtml = new StringBuilder();
|
||||
String layoutType = group.layoutType();
|
||||
String slideTemplate;
|
||||
|
||||
if ("gallery".equals(layoutType)) {
|
||||
slideTemplate = getSlideTemplateForSlug("gallery");
|
||||
} else if ("card".equals(layoutType)) {
|
||||
slideTemplate = getSlideTemplateForSlug("chung-chi");
|
||||
} else if ("large-image".equals(layoutType)) {
|
||||
slideTemplate = getSlideTemplateForSlug("hinh-anh-thuc-hanh");
|
||||
} else if ("custom".equals(layoutType) && group.itemTemplate() != null && !group.itemTemplate().trim().isEmpty()) {
|
||||
slideTemplate = group.itemTemplate();
|
||||
} else {
|
||||
// Fallback to legacy slug-based template if no matching layoutType or empty custom template
|
||||
slideTemplate = getSlideTemplateForSlug(slug);
|
||||
}
|
||||
|
||||
for (SwiperSliderAdminController.SliderItem item : group.items()) {
|
||||
String slide = slideTemplate
|
||||
.replace("{{title}}", item.title() != null ? item.title() : "")
|
||||
.replace("{{imageUrl}}", item.imageUrl() != null ? item.imageUrl() : "")
|
||||
.replace("{{linkUrl}}", item.linkUrl() != null ? item.linkUrl() : "")
|
||||
.replace("{{description}}", item.description() != null ? item.description() : "");
|
||||
slidesHtml.append(slide);
|
||||
}
|
||||
return slidesHtml.toString();
|
||||
}
|
||||
|
||||
private String getSlideTemplateForSlug(String slug) {
|
||||
if ("gallery".equals(slug)) {
|
||||
return "<div class=\"swiper-slide max-w-[356px] !mr-2 lg:!mr-3 xl:!mr-4\" style=\"width: 356px; max-width: 100%;\"><div class=\"relative rounded-lg aspect-[3/2] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
|
||||
}
|
||||
if ("chung-chi".equals(slug) || "giay-chung-nhan".equals(slug) || "thuc-hanh".equals(slug) || "xac-nhan-thuc-hanh".equals(slug)) {
|
||||
return "<div class=\"swiper-slide h-auto max-w-[288px] mr-2 lg:!mr-3 xl:!mr-4\" style=\"width: 288px; max-width: 100%;\"><a href=\"{{linkUrl}}\" class=\"group h-full flex flex-col items-start gap-y-4 lg:gap-y-5 rounded-2xl bg-white p-4 pb-5 shadow-sm transition-shadow hover:shadow-md border border-transparent hover:border-primary-100\"><div class=\"relative w-full aspect-square overflow-hidden rounded-xl\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 size-full object-cover transition-transform duration-500 group-hover:scale-105\"></div><div class=\"flex flex-1 flex-col justify-between w-full\"><h3 class=\"heading-5 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-3\">{{title}}</h3></div></a></div>";
|
||||
}
|
||||
if ("hinh-anh-thuc-hanh".equals(slug)) {
|
||||
return "<div class=\"swiper-slide max-w-[789px] !mr-2\" style=\"width: 789px; max-width: 100%;\"><div class=\"relative rounded-lg aspect-[789/460] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
|
||||
}
|
||||
return "<div class=\"swiper-slide\"><div class=\"relative rounded-lg aspect-[3/2] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
|
||||
}
|
||||
|
||||
private String extractSlideTemplate(String template) {
|
||||
int start = template.indexOf("{{#each items}}");
|
||||
int end = template.indexOf("{{/each}}");
|
||||
if (start != -1 && end != -1) {
|
||||
return template.substring(start + 15, end);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private void seedDefaultData() {
|
||||
String json = settingService.getValue(SETTING_KEY, "");
|
||||
if (json.isEmpty() || "[]".equals(json) || !json.contains("chung-chi") || json.contains("UMC_files")) {
|
||||
String sampleData = "[{\"slug\":\"gallery\",\"name\":\"Gallery Đào Tạo\",\"items\":[" +
|
||||
"{\"title\":\"Khóa đào tạo 1\",\"imageUrl\":\"/images/dao-tao/image-6.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Khóa đào tạo 2\",\"imageUrl\":\"/images/dao-tao/image-5.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Khóa đào tạo 3\",\"imageUrl\":\"/images/dao-tao/image-4.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
||||
"]},{\"slug\":\"chung-chi\",\"name\":\"Cấp chứng chỉ\",\"items\":[" +
|
||||
"{\"title\":\"Lớp chứng chỉ 1\",\"imageUrl\":\"/images/dao-tao/image-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Lớp chứng chỉ 2\",\"imageUrl\":\"/images/dao-tao/image-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Lớp chứng chỉ 3\",\"imageUrl\":\"/images/dao-tao/image-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
||||
"]},{\"slug\":\"giay-chung-nhan\",\"name\":\"Cấp giấy chứng nhận\",\"items\":[" +
|
||||
"{\"title\":\"Giấy chứng nhận 1\",\"imageUrl\":\"/images/dao-tao/training-4.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Giấy chứng nhận 2\",\"imageUrl\":\"/images/dao-tao/training-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
||||
"]},{\"slug\":\"xac-nhan-thuc-hanh\",\"name\":\"Đào tạo cấp giấy xác nhận quá trình thực hành\",\"items\":[" +
|
||||
"{\"title\":\"Xác nhận thực hành 1\",\"imageUrl\":\"/images/dao-tao/PTNS2707.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Xác nhận thực hành 2\",\"imageUrl\":\"/images/dao-tao/training-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
||||
"]},{\"slug\":\"hinh-anh-thuc-hanh\",\"name\":\"Hình ảnh thực hành lâm sàng\",\"items\":[" +
|
||||
"{\"title\":\"Thực hành lâm sàng 1\",\"imageUrl\":\"/images/dao-tao/training-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
||||
"{\"title\":\"Thực hành lâm sàng 2\",\"imageUrl\":\"/images/dao-tao/training-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
||||
"]}]";
|
||||
settingService.setValue(SETTING_KEY, sampleData);
|
||||
LOG.info("Seeded default swiper slider data for all 5 training sliders");
|
||||
}
|
||||
}
|
||||
|
||||
private void seedComponentTemplate() {
|
||||
java.util.Optional<com.sisvietnamvn.web.domain.ComponentTemplate> opt = templateRepository.findBySlug("swiper-slider");
|
||||
if (opt.isEmpty()) {
|
||||
com.sisvietnamvn.web.domain.ComponentTemplate t = new com.sisvietnamvn.web.domain.ComponentTemplate();
|
||||
t.setSlug("swiper-slider");
|
||||
t.setName("Swiper Slider");
|
||||
t.setDescription("Template cho plugin Swiper Slider");
|
||||
t.setActive(true);
|
||||
t.setHtmlTemplate(getDefaultTemplate());
|
||||
templateRepository.save(t);
|
||||
LOG.info("Seeded default component template: swiper-slider");
|
||||
}
|
||||
}
|
||||
|
||||
private String getDefaultTemplate() {
|
||||
return """
|
||||
<div class="relative md:flex md:items-center md:gap-x-2 plugin-swiper-slider plugin-swiper-slider-{{slug}}">
|
||||
<button class="btn-navigation-{{slug}}-prev md:size-[42px] size-[32px] flex-shrink-0 items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group md:flex hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 -rotate-90"><path d="m18 15-6-6-6 6"></path></svg>
|
||||
</button>
|
||||
<div class="swiper swiper-{{slug}} w-full swiper-backface-hidden">
|
||||
<div class="swiper-wrapper">
|
||||
{{#each items}}
|
||||
<div class="swiper-slide max-w-[356px] mr-2 lg:!mr-3 xl:!mr-4" style="width: 356px;">
|
||||
<div class="relative rounded-lg aspect-[3/2] overflow-hidden">
|
||||
<img src="{{imageUrl}}" alt="{{title}}" class="absolute inset-0 w-full h-full object-cover">
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-navigation-{{slug}}-next md:size-[42px] size-[32px] flex-shrink-0 items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group md:flex hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 rotate-90"><path d="m18 15-6-6-6 6"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
if (typeof Swiper !== 'undefined') {
|
||||
new Swiper('.swiper-{{slug}}', {
|
||||
slidesPerView: 'auto',
|
||||
spaceBetween: 16,
|
||||
navigation: {
|
||||
nextEl: '.btn-navigation-{{slug}}-next',
|
||||
prevEl: '.btn-navigation-{{slug}}-prev',
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.sisvietnamvn.web.plugins.tabbedlivestream;
|
||||
|
||||
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/tabbed-livestream")
|
||||
public class TabbedLivestreamAdminController {
|
||||
|
||||
private final SettingService settingService;
|
||||
private final AdminContext adminContext;
|
||||
private final ObjectMapper objectMapper;
|
||||
private static final String SETTING_KEY = "plugin_tabbed_livestream_data";
|
||||
|
||||
public TabbedLivestreamAdminController(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<LivestreamTab> tabs = new ArrayList<>();
|
||||
try {
|
||||
tabs = objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (JsonProcessingException e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
model.addAttribute("tabs", tabs);
|
||||
model.addAttribute("tabsJson", json);
|
||||
model.addAttribute("pageTitle", "Quản lý Tabbed Livestream");
|
||||
|
||||
return "plugins/tabbed-livestream/admin-settings";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String save(@RequestParam(value = "tabData", required = false) String tabDataJson, RedirectAttributes redirectAttributes) {
|
||||
if (!adminContext.currentUserCan("manage_options")) {
|
||||
return "error/403";
|
||||
}
|
||||
|
||||
if (tabDataJson != null && !tabDataJson.trim().isEmpty()) {
|
||||
// Validate JSON before saving
|
||||
try {
|
||||
objectMapper.readValue(tabDataJson, new TypeReference<List<LivestreamTab>>() {});
|
||||
settingService.setValue(SETTING_KEY, tabDataJson);
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Đã lưu cấu hình thành công!");
|
||||
} catch (JsonProcessingException e) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Dữ liệu không hợp lệ!");
|
||||
}
|
||||
} else {
|
||||
settingService.setValue(SETTING_KEY, "[]");
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Đã xóa toàn bộ cấu hình!");
|
||||
}
|
||||
|
||||
return "redirect:/manage/plugins/tabbed-livestream";
|
||||
}
|
||||
|
||||
public record LivestreamTab(String title, String featuredImageUrl, String excerpt, String content, String metaDescriptionRaw, String slug) {}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.sisvietnamvn.web.plugins.tabbedlivestream;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.sisvietnamvn.web.domain.HtmlSnippet;
|
||||
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;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
public class TabbedLivestreamPlugin {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(TabbedLivestreamPlugin.class);
|
||||
private static final String PLUGIN_KEY = "tabbed-livestream-plugin";
|
||||
private static final String SNIPPET_SLUG = "tabbed-livestream-snippet";
|
||||
private static final String SETTING_KEY = "plugin_tabbed_livestream_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 TabbedLivestreamPlugin(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("Tabbed Livestream Component");
|
||||
newPlugin.setVersion("2.0");
|
||||
newPlugin.setStatus(PluginStatus.ACTIVE);
|
||||
newPlugin.setAuthor("Antigravity");
|
||||
newPlugin.setDescription("Livestream plugin with independent admin UI.");
|
||||
return pluginRepository.save(newPlugin);
|
||||
});
|
||||
isActive = p.getStatus() == PluginStatus.ACTIVE;
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Could not verify TabbedLivestreamPlugin status: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
LOG.info("TabbedLivestreamPlugin is inactive.");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.info("TabbedLivestreamPlugin is active. Seeding components and hooks...");
|
||||
|
||||
seedHtmlSnippet();
|
||||
seedComponentTemplate();
|
||||
seedDefaultData();
|
||||
|
||||
// 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/tabbed-livestream\">Livestream Config</a>\n";
|
||||
return html;
|
||||
}, 10);
|
||||
|
||||
// Hook to replace shortcode
|
||||
hookManager.addFilter("the_content", (content, args) -> {
|
||||
String text = content instanceof String ? (String) content : "";
|
||||
if (text.contains("[plugin:tabbed-livestream]")) {
|
||||
return text.replace("[plugin:tabbed-livestream]", generateLivestreamHtml());
|
||||
}
|
||||
return text;
|
||||
}, 10);
|
||||
|
||||
hookManager.addFilter("snippet_content", (content, args) -> {
|
||||
String text = content instanceof String ? (String) content : "";
|
||||
if (text.contains("[plugin:tabbed-livestream]")) {
|
||||
return text.replace("[plugin:tabbed-livestream]", generateLivestreamHtml());
|
||||
}
|
||||
return text;
|
||||
}, 10);
|
||||
}
|
||||
|
||||
private void seedHtmlSnippet() {
|
||||
Optional<HtmlSnippet> opt = snippetRepository.findBySlug(SNIPPET_SLUG);
|
||||
if (opt.isEmpty()) {
|
||||
HtmlSnippet s = new HtmlSnippet();
|
||||
s.setSlug(SNIPPET_SLUG);
|
||||
s.setName("Livestream Snippet (Plugin)");
|
||||
s.setActive(true);
|
||||
s.setContent("[plugin:tabbed-livestream]");
|
||||
snippetRepository.save(s);
|
||||
LOG.info("Seeded HtmlSnippet: {}.", SNIPPET_SLUG);
|
||||
} else {
|
||||
HtmlSnippet s = opt.get();
|
||||
if (!"[plugin:tabbed-livestream]".equals(s.getContent())) {
|
||||
s.setContent("[plugin:tabbed-livestream]");
|
||||
snippetRepository.save(s);
|
||||
LOG.info("Updated HtmlSnippet: {}.", SNIPPET_SLUG);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void seedDefaultData() {
|
||||
String json = settingService.getValue(SETTING_KEY, "");
|
||||
if (json.isEmpty() || "[]".equals(json) || json.contains("\"Mô tả livestream tuần 1\"") || json.contains("\"Tuần 1\"") || json.contains("\"1\"")) {
|
||||
String sampleData = "[{\"title\":\"Tuần 1\",\"featuredImageUrl\":\"https://sisvietnam.vn/wp-content/uploads/2019/01/IMG_6313.jpg\",\"excerpt\":\"Nhận biết và phòng ngừa đột quỵ\",\"content\":\"<p>Đột quỵ là một trong những nguyên nhân gây tử vong hàng đầu. Việc nhận biết sớm các dấu hiệu (F.A.S.T) và có biện pháp phòng ngừa kịp thời sẽ giúp giảm thiểu rủi ro và cứu sống người bệnh. Hãy cùng lắng nghe tư vấn từ các chuyên gia hàng đầu của S.I.S Cần Thơ.</p>\",\"metaDescriptionRaw\":\"<iframe width=\\\"560\\\" height=\\\"315\\\" src=\\\"https://www.youtube.com/embed/wXhTHyIgQ_U\\\" title=\\\"YouTube video player\\\" frameborder=\\\"0\\\" allow=\\\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\\\" allowfullscreen></iframe>\",\"slug\":\"/news/nhan-biet-phong-ngua-dot-quy\"},{\"title\":\"Tuần 2\",\"featuredImageUrl\":\"https://sisvietnam.vn/wp-content/uploads/2019/01/IMG_6313.jpg\",\"excerpt\":\"Sơ cứu người bệnh đột quỵ tại nhà\",\"content\":\"<p>Sơ cứu đúng cách trong 'thời gian vàng' là yếu tố quyết định khả năng phục hồi của bệnh nhân đột quỵ. Livestream này sẽ hướng dẫn chi tiết các bước sơ cứu cần thiết khi phát hiện người thân có dấu hiệu đột quỵ tại nhà.</p>\",\"metaDescriptionRaw\":\"<iframe width=\\\"560\\\" height=\\\"315\\\" src=\\\"https://www.youtube.com/embed/wXhTHyIgQ_U\\\" title=\\\"YouTube video player\\\" frameborder=\\\"0\\\" allow=\\\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\\\" allowfullscreen></iframe>\",\"slug\":\"/news/so-cuu-dot-quy\"},{\"title\":\"Tuần 3\",\"featuredImageUrl\":\"https://sisvietnam.vn/wp-content/uploads/2019/01/IMG_6313.jpg\",\"excerpt\":\"Chế độ dinh dưỡng cho người bệnh tim mạch\",\"content\":\"<p>Dinh dưỡng đóng vai trò vô cùng quan trọng trong việc bảo vệ sức khỏe tim mạch. Khám phá những thực phẩm tốt cho tim, thực đơn hàng ngày và những lưu ý cần tránh để duy trì một trái tim khỏe mạnh.</p>\",\"metaDescriptionRaw\":\"<iframe width=\\\"560\\\" height=\\\"315\\\" src=\\\"https://www.youtube.com/embed/wXhTHyIgQ_U\\\" title=\\\"YouTube video player\\\" frameborder=\\\"0\\\" allow=\\\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\\\" allowfullscreen></iframe>\",\"slug\":\"/news/dinh-duong-tim-mach\"},{\"title\":\"Tuần 4\",\"featuredImageUrl\":\"https://sisvietnam.vn/wp-content/uploads/2019/01/IMG_6313.jpg\",\"excerpt\":\"Phục hồi chức năng sau đột quỵ\",\"content\":\"<p>Hành trình phục hồi sau đột quỵ đòi hỏi sự kiên nhẫn và phương pháp điều trị đúng đắn. Các bác sĩ sẽ chia sẻ về các bài tập vật lý trị liệu, liệu pháp ngôn ngữ và cách chăm sóc bệnh nhân tại nhà để đạt hiệu quả phục hồi cao nhất.</p>\",\"metaDescriptionRaw\":\"<iframe width=\\\"560\\\" height=\\\"315\\\" src=\\\"https://www.youtube.com/embed/wXhTHyIgQ_U\\\" title=\\\"YouTube video player\\\" frameborder=\\\"0\\\" allow=\\\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\\\" allowfullscreen></iframe>\",\"slug\":\"/news/phuc-hoi-chuc-nang\"}]";
|
||||
settingService.setValue(SETTING_KEY, sampleData);
|
||||
LOG.info("Seeded default livestream 4 tabs data");
|
||||
}
|
||||
}
|
||||
|
||||
private void seedComponentTemplate() {
|
||||
java.util.Optional<com.sisvietnamvn.web.domain.ComponentTemplate> opt = templateRepository.findBySlug("tabbed-livestream");
|
||||
if (opt.isEmpty()) {
|
||||
com.sisvietnamvn.web.domain.ComponentTemplate t = new com.sisvietnamvn.web.domain.ComponentTemplate();
|
||||
t.setSlug("tabbed-livestream");
|
||||
t.setName("Tabbed Livestream");
|
||||
t.setDescription("Template cho giao diện Livestream");
|
||||
t.setActive(true);
|
||||
t.setHtmlTemplate(getDefaultTemplate());
|
||||
templateRepository.save(t);
|
||||
LOG.info("Seeded default component template: tabbed-livestream");
|
||||
} else {
|
||||
com.sisvietnamvn.web.domain.ComponentTemplate t = opt.get();
|
||||
if (t.getHtmlTemplate() != null && (t.getHtmlTemplate().contains("Tuần {{title}}") || t.getHtmlTemplate().contains("#c0272d"))) {
|
||||
t.setHtmlTemplate(t.getHtmlTemplate().replace("Tuần {{title}}", "{{title}}").replace("#c0272d", "var(--color-old-brick)"));
|
||||
templateRepository.save(t);
|
||||
LOG.info("Updated existing component template: tabbed-livestream to fix template HTML");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String getDefaultTemplate() {
|
||||
return """
|
||||
<style>
|
||||
/* ── Livestream Section ── */
|
||||
.ls-section { background: #f5f5f5;}
|
||||
.c--tabbed-media-content { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s2, 16px); overflow: hidden; padding: 36px var(--s5); !important;}
|
||||
|
||||
/* Left column */
|
||||
.ls-left { padding: 36px 32px 32px 0px; display: flex; flex-direction: column; min-height: 450px; }
|
||||
.ls-heading { font-size: 2rem; font-weight: 800; color: var(--color-brand); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 20px;}
|
||||
|
||||
/* Tabs */
|
||||
.ls-tabs { display: flex; gap: 0; border-bottom: 2px solid #e0e0e0; margin-bottom: 24px; }
|
||||
.ls-tab { padding: 8px 18px; font-size: 14px; font-weight: 600; color: #666; cursor: pointer; border-bottom: 3px solid transparent; margin-bottom: -2px; transition: all 0.2s; text-decoration: none; white-space: nowrap; }
|
||||
.ls-tab:hover { color: var(--color-old-brick); }
|
||||
.ls-tab.active { color: var(--color-old-brick); border-bottom-color: var(--color-old-brick); }
|
||||
|
||||
/* Tab panels */
|
||||
.ls-panel { display: none; flex-direction: column; flex: 1; }
|
||||
.ls-panel.active { display: flex; }
|
||||
.ls-panel-title { font-size: 18px; font-weight: 700; color: #222; margin-bottom: 14px; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.ls-panel-desc { line-height: 1.7; margin-bottom: 24px; flex: 1; display: -webkit-box; -webkit-line-clamp: 6; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.ls-btn { display: inline-block; padding: 10px 24px; background: var(--color-old-brick); color: #fff; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; border-radius: 4px; text-decoration: none; transition: background 0.2s; align-self: flex-start; }
|
||||
.ls-btn:hover { background: #a01f24; }
|
||||
|
||||
/* Right column — poster image */
|
||||
.ls-right { min-height: 100%; position: relative; overflow: hidden; }
|
||||
.ls-poster { display: none; width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
|
||||
.ls-poster.active { display: block; }
|
||||
.ls-poster .ls-thumbnail { width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 2; cursor: pointer; }
|
||||
.ls-poster .ls-thumbnail img { width: 100%; height: 100%; object-fit: cover; object-position: center; }
|
||||
.ls-play-btn { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 70px; height: 70px; background: rgba(192, 39, 45, 0.9); border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 3; transition: transform 0.2s, background 0.2s; }
|
||||
.ls-play-btn:hover { transform: translate(-50%, -50%) scale(1.1); background: var(--color-old-brick); }
|
||||
.ls-play-btn::after { content: ''; display: block; border-style: solid; border-width: 12px 0 12px 20px; border-color: transparent transparent transparent #fff; margin-left: 5px; }
|
||||
.ls-video-embed { display: none; width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 1; }
|
||||
.ls-video-embed iframe { width: 100%; height: 100%; border: none; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 900px) {
|
||||
.c--tabbed-media-content { grid-template-columns: 1fr; border-left: none; border-top: 5px solid var(--color-old-brick); }
|
||||
.ls-left { padding: 28px 20px 24px 0px; min-height: auto; }
|
||||
.ls-right { min-height: auto; aspect-ratio: 16/9; position: relative; }
|
||||
.ls-poster { position: absolute; width: 100%; height: 100%; }
|
||||
.ls-poster .ls-thumbnail img { height: 100%; width: 100%; object-fit: cover; object-position: center; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.ls-heading { font-size: 18px; }
|
||||
.ls-tab { padding: 6px 12px; font-size: 13px; }
|
||||
.ls-panel-title { font-size: 16px; -webkit-line-clamp: 2; }
|
||||
.ls-panel-desc { font-size: 13px; -webkit-line-clamp: 4; }
|
||||
.ls-play-btn { width: 50px; height: 50px; }
|
||||
.ls-play-btn::after { border-width: 9px 0 9px 15px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="cc--component-container cc--tabbed-media-content ls-section">
|
||||
<div class="c--component c--tabbed-media-content">
|
||||
<!-- Left Column -->
|
||||
<div class="ls-left">
|
||||
<h2 class="ls-heading">Livestream Tháng 07</h2>
|
||||
<div class="ls-tabs">
|
||||
{{#each livestream_posts}}
|
||||
<a href="javascript:void(0)" class="ls-tab{{#if @first}} active{{/if}}" data-tab="ls-panel-{{index}}" onclick="switchLsTab(this)">{{title}}</a>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{#each livestream_posts}}
|
||||
<div class="ls-panel{{#if @first}} active{{/if}}" id="ls-panel-{{index}}">
|
||||
<h3 class="ls-panel-title">{{excerpt}}</h3>
|
||||
<div class="ls-panel-desc">{{content}}</div>
|
||||
<a href="{{slug}}" class="ls-btn">Tìm Hiểu Thêm</a>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
<!-- Right Column — Poster -->
|
||||
<div class="ls-right">
|
||||
{{#each livestream_posts}}
|
||||
<div class="ls-poster{{#if @first}} active{{/if}}" data-poster="ls-panel-{{index}}">
|
||||
<div class="ls-thumbnail" onclick="playLsVideo('ls-panel-{{index}}')">
|
||||
<img src="{{featuredImageUrl}}" alt="{{title}}" loading="lazy">
|
||||
<div class="ls-play-btn"></div>
|
||||
</div>
|
||||
<div class="ls-video-embed" id="ls-video-{{index}}">
|
||||
{{{metaDescriptionRaw}}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchLsTab(el) {
|
||||
var tabId = el.getAttribute('data-tab');
|
||||
document.querySelectorAll('.ls-tab').forEach(function(t){t.classList.remove('active');});
|
||||
el.classList.add('active');
|
||||
document.querySelectorAll('.ls-panel').forEach(function(p){p.classList.remove('active');});
|
||||
var panel = document.getElementById(tabId);
|
||||
if(panel) panel.classList.add('active');
|
||||
document.querySelectorAll('.ls-poster').forEach(function(p){
|
||||
p.classList.remove('active');
|
||||
var iframe = p.querySelector('iframe');
|
||||
if(iframe) {
|
||||
var src = iframe.src;
|
||||
iframe.src = src;
|
||||
}
|
||||
});
|
||||
var poster = document.querySelector('.ls-poster[data-poster="'+tabId+'"]');
|
||||
if(poster) poster.classList.add('active');
|
||||
}
|
||||
function playLsVideo(tabId) {
|
||||
var poster = document.querySelector('.ls-poster[data-poster="'+tabId+'"]');
|
||||
if(poster) {
|
||||
var thumb = poster.querySelector('.ls-thumbnail');
|
||||
var embed = poster.querySelector('.ls-video-embed');
|
||||
var iframe = embed.querySelector('iframe');
|
||||
if (thumb) thumb.style.display = 'none';
|
||||
if (embed) {
|
||||
embed.style.display = 'block';
|
||||
embed.style.zIndex = '4';
|
||||
if (iframe && iframe.src.indexOf('autoplay=1') === -1) {
|
||||
if (iframe.src.indexOf('?') > -1) iframe.src += '&autoplay=1';
|
||||
else iframe.src += '?autoplay=1';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var first = document.querySelector('.ls-tab');
|
||||
if(first) switchLsTab(first);
|
||||
});
|
||||
</script>
|
||||
""";
|
||||
}
|
||||
|
||||
private String generateLivestreamHtml() {
|
||||
String json = settingService.getValue(SETTING_KEY, "[]");
|
||||
List<TabbedLivestreamAdminController.LivestreamTab> tabs = new ArrayList<>();
|
||||
try {
|
||||
tabs = objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (JsonProcessingException e) {
|
||||
LOG.error("Failed to parse livestream tabs JSON: ", e);
|
||||
}
|
||||
|
||||
if (tabs.isEmpty()) {
|
||||
return "<p class='text-muted text-center'>Chưa có dữ liệu Livestream.</p>";
|
||||
}
|
||||
|
||||
java.util.Optional<com.sisvietnamvn.web.domain.ComponentTemplate> opt = templateRepository.findBySlug("tabbed-livestream");
|
||||
String html = opt.map(com.sisvietnamvn.web.domain.ComponentTemplate::getHtmlTemplate).orElse(getDefaultTemplate());
|
||||
|
||||
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\{\\{#each\\s+livestream_posts\\s*}}(.*?)\\{\\{/each\\s*}}", java.util.regex.Pattern.DOTALL).matcher(html);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (matcher.find()) {
|
||||
String loopBody = matcher.group(1);
|
||||
StringBuilder rendered = new StringBuilder();
|
||||
for (int i = 0; i < tabs.size(); i++) {
|
||||
TabbedLivestreamAdminController.LivestreamTab tab = tabs.get(i);
|
||||
String itemHtml = loopBody;
|
||||
itemHtml = itemHtml.replace("{{#if @first}} active{{/if}}", i == 0 ? " active" : "");
|
||||
itemHtml = itemHtml.replace("{{index}}", String.valueOf(i));
|
||||
itemHtml = itemHtml.replace("{{title}}", tab.title() != null ? tab.title() : "");
|
||||
itemHtml = itemHtml.replace("{{excerpt}}", tab.excerpt() != null ? tab.excerpt().replace("\n", "<br/>") : "");
|
||||
itemHtml = itemHtml.replace("{{content}}", tab.content() != null ? tab.content().replace("\n", "<br/>") : "");
|
||||
itemHtml = itemHtml.replace("{{slug}}", tab.slug() != null ? tab.slug() : "#");
|
||||
itemHtml = itemHtml.replace("{{featuredImageUrl}}", tab.featuredImageUrl() != null ? tab.featuredImageUrl() : "");
|
||||
itemHtml = itemHtml.replace("{{{metaDescriptionRaw}}}", tab.metaDescriptionRaw() != null ? tab.metaDescriptionRaw() : "");
|
||||
rendered.append(itemHtml);
|
||||
}
|
||||
matcher.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(rendered.toString()));
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.sisvietnamvn.web.plugins.tableextension;
|
||||
|
||||
import com.sisvietnamvn.web.service.SettingService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/manage/plugins/table-extension")
|
||||
public class TableExtensionController {
|
||||
|
||||
private final SettingService settingService;
|
||||
private static final String TEMPLATES_DIR = "src/main/resources/templates/manage";
|
||||
|
||||
public TableExtensionController(SettingService settingService) {
|
||||
this.settingService = settingService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String showSettings(Model model) {
|
||||
Map<String, List<String>> tableLocations = new HashMap<>();
|
||||
List<String> allTables = new ArrayList<>();
|
||||
try {
|
||||
Path managePath = Paths.get(TEMPLATES_DIR);
|
||||
if (Files.exists(managePath)) {
|
||||
try (Stream<Path> paths = Files.walk(managePath)) {
|
||||
List<Path> htmlFiles = paths.filter(Files::isRegularFile)
|
||||
.filter(p -> p.toString().endsWith(".html"))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Pattern tablePattern = Pattern.compile("<table[^>]+id=[\"']([^\"']+)[\"']");
|
||||
for (Path p : htmlFiles) {
|
||||
String content = Files.readString(p);
|
||||
Matcher matcher = tablePattern.matcher(content);
|
||||
while (matcher.find()) {
|
||||
String id = matcher.group(1);
|
||||
if (!allTables.contains(id)) {
|
||||
allTables.add(id);
|
||||
}
|
||||
tableLocations.computeIfAbsent(id, k -> new ArrayList<>()).add(managePath.relativize(p).toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
String activeTablesStr = settingService.getValue("table_extension_active_ids", "");
|
||||
List<String> activeTables = new ArrayList<>();
|
||||
if (activeTablesStr != null && !activeTablesStr.trim().isEmpty()) {
|
||||
activeTables.addAll(Arrays.asList(activeTablesStr.split(",")));
|
||||
}
|
||||
|
||||
model.addAttribute("allTables", allTables);
|
||||
model.addAttribute("tableLocations", tableLocations);
|
||||
model.addAttribute("activeTables", activeTables);
|
||||
return "manage/plugins/table-extension-settings";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String saveSettings(@RequestParam(value = "activeTables", required = false) List<String> activeTables) {
|
||||
if (activeTables == null) {
|
||||
activeTables = new ArrayList<>();
|
||||
}
|
||||
String activeTablesStr = String.join(",", activeTables);
|
||||
settingService.setValue("table_extension_active_ids", activeTablesStr);
|
||||
return "redirect:/manage/plugins/table-extension?success";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.sisvietnamvn.web.plugins.tableextension;
|
||||
|
||||
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 com.sisvietnamvn.web.domain.Plugin;
|
||||
import com.sisvietnamvn.web.domain.PluginStatus;
|
||||
import com.sisvietnamvn.web.hook.HookManager;
|
||||
import com.sisvietnamvn.web.repository.PluginRepository;
|
||||
import com.sisvietnamvn.web.service.SettingService;
|
||||
|
||||
@Component
|
||||
public class TableExtensionPlugin {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(TableExtensionPlugin.class);
|
||||
|
||||
private final HookManager hookManager;
|
||||
private final PluginRepository pluginRepository;
|
||||
private final SettingService settingService;
|
||||
private static final String PLUGIN_KEY = "tableextension";
|
||||
|
||||
public TableExtensionPlugin(HookManager hookManager, PluginRepository pluginRepository, SettingService settingService) {
|
||||
this.hookManager = hookManager;
|
||||
this.pluginRepository = pluginRepository;
|
||||
this.settingService = settingService;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void registerHooks() {
|
||||
boolean isActive = false;
|
||||
try {
|
||||
// Auto-register plugin in database if it doesn't exist
|
||||
Plugin p = pluginRepository.findByPluginKey(PLUGIN_KEY).orElseGet(() -> {
|
||||
Plugin newPlugin = new Plugin();
|
||||
newPlugin.setPluginKey(PLUGIN_KEY);
|
||||
newPlugin.setName("Table Pagination & Search Extension");
|
||||
newPlugin.setVersion("1.0");
|
||||
newPlugin.setStatus(PluginStatus.ACTIVE);
|
||||
newPlugin.setAuthor("Antigravity");
|
||||
newPlugin.setDescription("Adds pagination and search functionality to configured tables.");
|
||||
return pluginRepository.save(newPlugin);
|
||||
});
|
||||
isActive = p.getStatus() == PluginStatus.ACTIVE;
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Could not verify plugin status (table might not exist yet): {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
LOG.info("TableExtensionPlugin is inactive.");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.info("Registering TableExtensionPlugin hooks...");
|
||||
|
||||
hookManager.addFilter("admin_footer_scripts", (value, args) -> {
|
||||
String existingScripts = value instanceof String ? (String) value : "";
|
||||
|
||||
// Get active tables from settings
|
||||
String activeTablesStr = settingService.getValue("table_extension_active_ids", "");
|
||||
if (activeTablesStr.isEmpty()) {
|
||||
return existingScripts; // No tables configured
|
||||
}
|
||||
|
||||
String[] tableIds = activeTablesStr.split(",");
|
||||
StringBuilder activeTablesJsArr = new StringBuilder("[");
|
||||
for (int i = 0; i < tableIds.length; i++) {
|
||||
activeTablesJsArr.append("'").append(tableIds[i].trim()).append("'");
|
||||
if (i < tableIds.length - 1) activeTablesJsArr.append(",");
|
||||
}
|
||||
activeTablesJsArr.append("]");
|
||||
|
||||
String pluginScript = getPluginScript(activeTablesJsArr.toString());
|
||||
return existingScripts + "\n" + pluginScript;
|
||||
}, 10);
|
||||
}
|
||||
|
||||
private String getPluginScript(String activeTablesJsArr) {
|
||||
return "<script>\n" +
|
||||
"class SimpleDataTable {\n" +
|
||||
" constructor(tableSelector, options = {}) {\n" +
|
||||
" this.table = typeof tableSelector === 'string' ? document.querySelector(tableSelector) : tableSelector;\n" +
|
||||
" if (!this.table) return;\n" +
|
||||
" this.options = Object.assign({\n" +
|
||||
" pageLength: 10,\n" +
|
||||
" lengthMenu: [10, 25, 50, 100],\n" +
|
||||
" searchPlaceholder: \"Tìm kiếm...\"\n" +
|
||||
" }, options);\n" +
|
||||
" this.tbody = this.table.querySelector('tbody');\n" +
|
||||
" if (!this.tbody) return;\n" +
|
||||
" this.originalRows = Array.from(this.tbody.querySelectorAll('tr'));\n" +
|
||||
" this.filteredRows = [...this.originalRows];\n" +
|
||||
" this.currentPage = 1;\n" +
|
||||
" this.pageLength = this.options.pageLength;\n" +
|
||||
" this.initUI();\n" +
|
||||
" this.render();\n" +
|
||||
" }\n" +
|
||||
" initUI() {\n" +
|
||||
" const wrapper = document.createElement('div');\n" +
|
||||
" wrapper.className = 'sdt-wrapper';\n" +
|
||||
" this.table.parentNode.insertBefore(wrapper, this.table);\n" +
|
||||
" wrapper.appendChild(this.table);\n" +
|
||||
" const topControls = document.createElement('div');\n" +
|
||||
" topControls.className = 'd-flex justify-content-between align-items-center mb-3';\n" +
|
||||
" const lengthMenuDiv = document.createElement('div');\n" +
|
||||
" lengthMenuDiv.innerHTML = `\n" +
|
||||
" <label class=\"mb-0 d-flex align-items-center text-sm font-weight-normal\">\n" +
|
||||
" Hiển thị \n" +
|
||||
" <select class=\"custom-select custom-select-sm mx-2 w-auto\">\n" +
|
||||
" ${this.options.lengthMenu.map(len => `<option value=\"${len}\" ${len === this.pageLength ? 'selected' : ''}>${len}</option>`).join('')}\n" +
|
||||
" <option value=\"-1\">Tất cả</option>\n" +
|
||||
" </select>\n" +
|
||||
" dòng\n" +
|
||||
" </label>\n" +
|
||||
" `;\n" +
|
||||
" this.lengthSelect = lengthMenuDiv.querySelector('select');\n" +
|
||||
" this.lengthSelect.addEventListener('change', (e) => {\n" +
|
||||
" this.pageLength = parseInt(e.target.value);\n" +
|
||||
" this.currentPage = 1;\n" +
|
||||
" this.render();\n" +
|
||||
" });\n" +
|
||||
" const searchDiv = document.createElement('div');\n" +
|
||||
" searchDiv.innerHTML = `\n" +
|
||||
" <label class=\"mb-0 d-flex align-items-center font-weight-normal\">\n" +
|
||||
" <input type=\"search\" class=\"form-control form-control-sm ml-2\" placeholder=\"${this.options.searchPlaceholder}\">\n" +
|
||||
" </label>\n" +
|
||||
" `;\n" +
|
||||
" this.searchInput = searchDiv.querySelector('input');\n" +
|
||||
" this.searchInput.addEventListener('input', (e) => {\n" +
|
||||
" const query = e.target.value.toLowerCase();\n" +
|
||||
" this.filteredRows = this.originalRows.filter(row => {\n" +
|
||||
" return row.textContent.toLowerCase().includes(query);\n" +
|
||||
" });\n" +
|
||||
" this.currentPage = 1;\n" +
|
||||
" this.render();\n" +
|
||||
" });\n" +
|
||||
" topControls.appendChild(lengthMenuDiv);\n" +
|
||||
" topControls.appendChild(searchDiv);\n" +
|
||||
" wrapper.insertBefore(topControls, this.table);\n" +
|
||||
" this.bottomControls = document.createElement('div');\n" +
|
||||
" this.bottomControls.className = 'd-flex justify-content-between align-items-center mt-3';\n" +
|
||||
" this.infoDiv = document.createElement('div');\n" +
|
||||
" this.infoDiv.className = 'sdt-info text-muted small';\n" +
|
||||
" this.paginationDiv = document.createElement('ul');\n" +
|
||||
" this.paginationDiv.className = 'pagination pagination-sm mb-0';\n" +
|
||||
" this.bottomControls.appendChild(this.infoDiv);\n" +
|
||||
" this.bottomControls.appendChild(this.paginationDiv);\n" +
|
||||
" wrapper.appendChild(this.bottomControls);\n" +
|
||||
" }\n" +
|
||||
" render() {\n" +
|
||||
" this.tbody.innerHTML = '';\n" +
|
||||
" const totalRows = this.filteredRows.length;\n" +
|
||||
" if (totalRows === 0) {\n" +
|
||||
" this.tbody.innerHTML = '<tr><td colspan=\"100%\" class=\"text-center text-muted py-3\">Không tìm thấy dữ liệu phù hợp</td></tr>';\n" +
|
||||
" this.infoDiv.innerText = 'Hiển thị 0 đến 0 của 0 dòng';\n" +
|
||||
" this.renderPagination(0);\n" +
|
||||
" return;\n" +
|
||||
" }\n" +
|
||||
" let start = 0;\n" +
|
||||
" let end = totalRows;\n" +
|
||||
" if (this.pageLength > 0) {\n" +
|
||||
" const totalPages = Math.ceil(totalRows / this.pageLength);\n" +
|
||||
" if (this.currentPage > totalPages) this.currentPage = totalPages;\n" +
|
||||
" if (this.currentPage < 1) this.currentPage = 1;\n" +
|
||||
" start = (this.currentPage - 1) * this.pageLength;\n" +
|
||||
" end = Math.min(start + this.pageLength, totalRows);\n" +
|
||||
" }\n" +
|
||||
" const fragment = document.createDocumentFragment();\n" +
|
||||
" for (let i = start; i < end; i++) {\n" +
|
||||
" fragment.appendChild(this.filteredRows[i]);\n" +
|
||||
" }\n" +
|
||||
" this.tbody.appendChild(fragment);\n" +
|
||||
" this.infoDiv.innerText = `Hiển thị ${start + 1} đến ${end} của ${totalRows} dòng`;\n" +
|
||||
" this.renderPagination(totalRows);\n" +
|
||||
" }\n" +
|
||||
" renderPagination(totalRows) {\n" +
|
||||
" this.paginationDiv.innerHTML = '';\n" +
|
||||
" if (this.pageLength <= 0 || totalRows <= this.pageLength) return;\n" +
|
||||
" const totalPages = Math.ceil(totalRows / this.pageLength);\n" +
|
||||
" const createLi = (text, page, disabled = false, active = false) => {\n" +
|
||||
" const li = document.createElement('li');\n" +
|
||||
" li.className = `page-item ${disabled ? 'disabled' : ''} ${active ? 'active' : ''}`;\n" +
|
||||
" li.innerHTML = `<a class=\"page-link\" href=\"javascript:void(0)\">${text}</a>`;\n" +
|
||||
" if (!disabled && !active) {\n" +
|
||||
" li.addEventListener('click', () => {\n" +
|
||||
" this.currentPage = page;\n" +
|
||||
" this.render();\n" +
|
||||
" });\n" +
|
||||
" }\n" +
|
||||
" return li;\n" +
|
||||
" };\n" +
|
||||
" this.paginationDiv.appendChild(createLi('Trang trước', this.currentPage - 1, this.currentPage === 1));\n" +
|
||||
" for (let i = 1; i <= totalPages; i++) {\n" +
|
||||
" if (totalPages > 7) {\n" +
|
||||
" if (i === 1 || i === totalPages || (i >= this.currentPage - 1 && i <= this.currentPage + 1)) {\n" +
|
||||
" this.paginationDiv.appendChild(createLi(i, i, false, i === this.currentPage));\n" +
|
||||
" } else if (i === this.currentPage - 2 || i === this.currentPage + 2) {\n" +
|
||||
" this.paginationDiv.appendChild(createLi('...', i, true));\n" +
|
||||
" }\n" +
|
||||
" } else {\n" +
|
||||
" this.paginationDiv.appendChild(createLi(i, i, false, i === this.currentPage));\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
" this.paginationDiv.appendChild(createLi('Trang sau', this.currentPage + 1, this.currentPage === totalPages));\n" +
|
||||
" }\n" +
|
||||
"}\n" +
|
||||
"document.addEventListener('DOMContentLoaded', function() {\n" +
|
||||
" const activeTables = " + activeTablesJsArr + ";\n" +
|
||||
" activeTables.forEach(id => {\n" +
|
||||
" const table = document.getElementById(id);\n" +
|
||||
" if (table) {\n" +
|
||||
" new SimpleDataTable(table, { pageLength: 10, lengthMenu: [10, 25, 50, 100], searchPlaceholder: 'Tìm kiếm...' });\n" +
|
||||
" const responsiveDiv = table.closest('.table-responsive');\n" +
|
||||
" if (responsiveDiv && responsiveDiv.nextElementSibling) {\n" +
|
||||
" const nextEl = responsiveDiv.nextElementSibling;\n" +
|
||||
" if (nextEl.querySelector('.pagination')) {\n" +
|
||||
" nextEl.style.display = 'none';\n" +
|
||||
" nextEl.classList.add('server-pagination-hidden');\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
" });\n" +
|
||||
"});\n" +
|
||||
"</script>";
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
package com.sisvietnamvn.web.repository;
|
||||
|
||||
import com.sisvietnamvn.web.domain.PageStatus;
|
||||
import com.sisvietnamvn.web.domain.Post;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import com.sisvietnamvn.web.domain.PageStatus;
|
||||
import com.sisvietnamvn.web.domain.Post;
|
||||
|
||||
/**
|
||||
* Spring Data JPA repository for the {@link Post} entity.
|
||||
*/
|
||||
|
||||
@@ -61,39 +61,7 @@ public class DoctorApiSyncService {
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getDoctorSchedule(String doctorCode) {
|
||||
// ALWAYS check manual schedule first
|
||||
List<com.sisvietnamvn.web.domain.DoctorSchedule> schedules = doctorScheduleRepository.findByDoctor_DoctorCode(doctorCode);
|
||||
if (!schedules.isEmpty()) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (com.sisvietnamvn.web.domain.DoctorSchedule s : schedules) {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("date", s.getDate());
|
||||
map.put("dayOfWeek", s.getDayOfWeek());
|
||||
result.add(map);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String mode = settingService.getValue("doctor.sync.mode", "SCHEDULED");
|
||||
|
||||
if ("MANUAL".equals(mode)) {
|
||||
return new ArrayList<>(); // Already checked manual above, so return empty
|
||||
}
|
||||
|
||||
if ("REALTIME".equals(mode)) {
|
||||
return fetchRealtimeSchedule(doctorCode);
|
||||
}
|
||||
|
||||
// SCHEDULED mode
|
||||
for (Map<String, Object> doc : cachedDoctors) {
|
||||
if (doctorCode.equals(doc.get("doctorCode"))) {
|
||||
Object daysObj = doc.get("days");
|
||||
if (daysObj instanceof List) {
|
||||
return (List<Map<String, Object>>) daysObj;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>();
|
||||
return fetchRealtimeSchedule(doctorCode);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> fetchRealtimeSchedule(String doctorCode) {
|
||||
@@ -148,29 +116,12 @@ public class DoctorApiSyncService {
|
||||
return daysList;
|
||||
}
|
||||
|
||||
private long lastSyncTime = 0;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
syncDoctorsFromApi();
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 60000)
|
||||
public void checkAndSync() {
|
||||
int intervalMinutes = 480; // default 8 hours
|
||||
try {
|
||||
intervalMinutes = Integer.parseInt(settingService.getValue("doctor.sync.interval", "480"));
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warn("Invalid doctor.sync.interval, using default 480");
|
||||
}
|
||||
|
||||
if (System.currentTimeMillis() - lastSyncTime >= intervalMinutes * 60L * 1000L) {
|
||||
syncDoctorsFromApi();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void syncDoctorsFromApi() {
|
||||
lastSyncTime = System.currentTimeMillis();
|
||||
try {
|
||||
LOG.info("Starting API sync for doctors...");
|
||||
|
||||
@@ -206,19 +157,12 @@ public class DoctorApiSyncService {
|
||||
String.class);
|
||||
|
||||
JsonNode root = objectMapper.readTree(apiResponse.getBody());
|
||||
try {
|
||||
java.nio.file.Files.writeString(java.nio.file.Paths.get("/tmp/debug_api.json"), root.toPrettyString());
|
||||
} catch (Exception e) {
|
||||
}
|
||||
JsonNode doctorsNode = root.at("/data/doctors");
|
||||
|
||||
List<Map<String, Object>> newDoctorsList = new ArrayList<>();
|
||||
if (doctorsNode.isArray()) {
|
||||
for (JsonNode node : doctorsNode) {
|
||||
Map<String, Object> doc = new HashMap<>();
|
||||
if (newDoctorsList.isEmpty()) {
|
||||
LOG.info("DEBUG API DOCTOR NODE: {}", node.toString());
|
||||
}
|
||||
doc.put("title",
|
||||
node.get("title") != null && !node.get("title").isNull() ? node.get("title").asText("")
|
||||
: "BS");
|
||||
@@ -245,9 +189,6 @@ public class DoctorApiSyncService {
|
||||
org.springframework.data.domain.PageRequest.of(0, 1));
|
||||
if (mediaPage.hasContent()) {
|
||||
avatarUrl = mediaPage.getContent().get(0).getFileUrl();
|
||||
LOG.info("Matched avatar for doctor {}: {}", doctorCode, avatarUrl);
|
||||
} else {
|
||||
LOG.info("No avatar found for doctor {}", doctorCode);
|
||||
}
|
||||
}
|
||||
doc.put("avatarUrl", avatarUrl);
|
||||
@@ -259,53 +200,7 @@ public class DoctorApiSyncService {
|
||||
|
||||
List<Map<String, Object>> daysList = new ArrayList<>();
|
||||
|
||||
// Check manual schedule first
|
||||
List<com.sisvietnamvn.web.domain.DoctorSchedule> manualSchedules = doctorScheduleRepository.findByDoctor_DoctorCode(doctorCode);
|
||||
if (!manualSchedules.isEmpty()) {
|
||||
for (com.sisvietnamvn.web.domain.DoctorSchedule s : manualSchedules) {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("date", s.getDate());
|
||||
map.put("dayOfWeek", s.getDayOfWeek());
|
||||
daysList.add(map);
|
||||
}
|
||||
} else {
|
||||
String mode = settingService.getValue("doctor.sync.mode", "SCHEDULED");
|
||||
if ("SCHEDULED".equals(mode) && !doctorCode.isEmpty()) {
|
||||
try {
|
||||
String step3Url = "https://dhs.sisvietnam.vn/api/app/his/doctors/" + doctorCode
|
||||
+ "/work-schedule-days";
|
||||
ResponseEntity<String> step3Response = restTemplate.exchange(step3Url, HttpMethod.GET,
|
||||
apiRequest, String.class);
|
||||
String rawJson = step3Response.getBody();
|
||||
|
||||
JsonNode step3Root = objectMapper.readTree(rawJson);
|
||||
JsonNode daysArrayNode = null;
|
||||
if (step3Root.has("data") && step3Root.get("data").has("days")
|
||||
&& step3Root.get("data").get("days").isArray()) {
|
||||
daysArrayNode = step3Root.get("data").get("days");
|
||||
}
|
||||
|
||||
if (daysArrayNode != null && daysArrayNode.isArray()) {
|
||||
for (JsonNode dayNode : daysArrayNode) {
|
||||
Map<String, Object> dayInfo = new HashMap<>();
|
||||
if (dayNode.has("dayOfWeek")) {
|
||||
dayInfo.put("dayOfWeek", dayNode.get("dayOfWeek").asInt());
|
||||
}
|
||||
if (dayNode.has("date")) {
|
||||
dayInfo.put("date", dayNode.get("date").asText());
|
||||
}
|
||||
if (!dayInfo.isEmpty()) {
|
||||
daysList.add(dayInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to fetch work-schedule-days for doctor: {}", doctorCode, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.put("days", daysList);
|
||||
|
||||
newDoctorsList.add(doc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,14 @@ public class HtmlSnippetService {
|
||||
private final SettingService settingService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ComponentTemplateService componentTemplateService;
|
||||
private final com.sisvietnamvn.web.hook.HookManager hookManager;
|
||||
|
||||
public HtmlSnippetService(HtmlSnippetRepository snippetRepository, SettingService settingService, ObjectMapper objectMapper, ComponentTemplateService componentTemplateService) {
|
||||
public HtmlSnippetService(HtmlSnippetRepository snippetRepository, SettingService settingService, ObjectMapper objectMapper, ComponentTemplateService componentTemplateService, com.sisvietnamvn.web.hook.HookManager hookManager) {
|
||||
this.snippetRepository = snippetRepository;
|
||||
this.settingService = settingService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.componentTemplateService = componentTemplateService;
|
||||
this.hookManager = hookManager;
|
||||
}
|
||||
|
||||
public List<HtmlSnippet> findAll() {
|
||||
@@ -105,6 +107,8 @@ public class HtmlSnippetService {
|
||||
cm.appendTail(csb);
|
||||
content = csb.toString();
|
||||
|
||||
content = (String) hookManager.applyFilters("snippet_content", content);
|
||||
|
||||
return "\n<!-- BEGIN Snippet: " + slug + " -->\n" + content + "\n<!-- END Snippet: " + slug + " -->\n";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="20260724150000-2" author="antigravity">
|
||||
<modifyDataType tableName="sis_setting" columnName="setting_value" newDataType="varchar2(4000)"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="20260724151500-1" author="antigravity">
|
||||
<!-- Add new CLOB column -->
|
||||
<addColumn tableName="sis_setting">
|
||||
<column name="setting_value_clob" type="clob"/>
|
||||
</addColumn>
|
||||
|
||||
<!-- Copy data -->
|
||||
<update tableName="sis_setting">
|
||||
<column name="setting_value_clob" valueComputed="setting_value"/>
|
||||
</update>
|
||||
|
||||
<!-- Drop old column -->
|
||||
<dropColumn tableName="sis_setting" columnName="setting_value"/>
|
||||
|
||||
<!-- Rename new column -->
|
||||
<renameColumn tableName="sis_setting" oldColumnName="setting_value_clob" newColumnName="setting_value" columnDataType="clob"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -48,4 +48,6 @@
|
||||
<include file="config/liquibase/changelog/20260723110000_add_active_to_doctor.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260723115000_delete_sample_doctors.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260723185500_add_doctor_schedule.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260724150000_alter_setting_value.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260724151500_alter_setting_value_to_clob.xml" relativeToChangelogFile="false"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -387,6 +387,9 @@ body, h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.umass-platform-homepage .header-hospital-name {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Desktop (>= 1025px) --- */
|
||||
@@ -530,6 +533,9 @@ body, h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.umass-platform-homepage .header-hospital-name {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Large Desktop (>= 1366px) --- */
|
||||
@@ -588,13 +594,6 @@ body, h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
||||
background: linear-gradient(180deg, transparent, #000) !important;
|
||||
}
|
||||
|
||||
.doctor-page #l--main-header {
|
||||
background-color: var(--color-old-brick);
|
||||
}
|
||||
|
||||
.doctor-page #l--main-header a{
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.transparent-header .region-header {
|
||||
background: linear-gradient(0deg, transparent, #000);
|
||||
@@ -608,12 +607,41 @@ body, h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.umass-platform-homepage .header-hospital-name {
|
||||
display: block;
|
||||
|
||||
|
||||
.bg-primary-600, .cc-component-container.cc--footer, .btn, .badge-primary {
|
||||
background-color: var(--color-old-brick) !important;
|
||||
border-color: var(--color-old-brick) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.bg-primary-600, .cc-component-container.cc--footer, .btn {
|
||||
i, .btn-outline-primary {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.page-item.active .page-link {
|
||||
z-index: 3;
|
||||
color: #fff !important;
|
||||
background-color: var(--color-old-brick);
|
||||
border-color: var(--color-old-brick);
|
||||
}
|
||||
|
||||
.bg-gradient-primary {
|
||||
background-color: var(--color-old-brick) !important;
|
||||
background-image: unset;
|
||||
}
|
||||
|
||||
.page-link,
|
||||
.text-primary,
|
||||
[class^="text-primary-"],
|
||||
[class*=" text-primary-"] {
|
||||
color: var(--color-old-brick) !important;
|
||||
}
|
||||
|
||||
#l--main-header {
|
||||
box-shadow: 0 .15rem 1.75rem 0 rgba(58, 59, 69, .15) !important;
|
||||
}
|
||||
|
||||
.bg-linear-service {
|
||||
background: linear-gradient(#60a7d700, var(--color-old-brick)) !important;
|
||||
}
|
||||
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1 @@
|
||||
:root{--swiper-navigation-size:44px}.swiper-button-next,.swiper-button-prev{position:absolute;top:var(--swiper-navigation-top-offset,50%);width:calc(var(--swiper-navigation-size) / 44 * 27);height:var(--swiper-navigation-size);margin-top:calc(0px - (var(--swiper-navigation-size) / 2));z-index:10;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--swiper-navigation-color,var(--swiper-theme-color))}.swiper-button-next.swiper-button-disabled,.swiper-button-prev.swiper-button-disabled{opacity:.35;cursor:auto;pointer-events:none}.swiper-button-next.swiper-button-hidden,.swiper-button-prev.swiper-button-hidden{opacity:0;cursor:auto;pointer-events:none}.swiper-navigation-disabled .swiper-button-next,.swiper-navigation-disabled .swiper-button-prev{display:none!important}.swiper-button-next svg,.swiper-button-prev svg{width:100%;height:100%;object-fit:contain;transform-origin:center}.swiper-rtl .swiper-button-next svg,.swiper-rtl .swiper-button-prev svg{transform:rotate(180deg)}.swiper-button-prev,.swiper-rtl .swiper-button-next{left:var(--swiper-navigation-sides-offset,10px);right:auto}.swiper-button-lock{display:none}.swiper-button-next:after,.swiper-button-prev:after{font-family:swiper-icons;font-size:var(--swiper-navigation-size);text-transform:none!important;letter-spacing:0;font-variant:normal;line-height:1}.swiper-button-prev:after,.swiper-rtl .swiper-button-next:after{content:"prev"}.swiper-button-next,.swiper-rtl .swiper-button-prev{right:var(--swiper-navigation-sides-offset,10px);left:auto}.swiper-button-next:after,.swiper-rtl .swiper-button-prev:after{content:"next"}
|
||||
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 205 KiB |
@@ -0,0 +1 @@
|
||||
/* PLEASE DO NOT COPY AND PASTE THIS CODE. */(function(){var w=window,C='___grecaptcha_cfg',cfg=w[C]=w[C]||{},N='grecaptcha';var gr=w[N]=w[N]||{};gr.ready=gr.ready||function(f){(cfg['fns']=cfg['fns']||[]).push(f);};w['__recaptcha_api']='https://www.google.com/recaptcha/api2/';(cfg['render']=cfg['render']||[]).push('onload');(cfg['anchor-ms']=cfg['anchor-ms']||[]).push(20000);(cfg['execute-ms']=cfg['execute-ms']||[]).push(30000);w['__google_recaptcha_client']=true;var d=document,po=d.createElement('script');po.type='text/javascript';po.async=true; po.charset='utf-8';var v=w.navigator,m=d.createElement('meta');m.httpEquiv='origin-trial';m.content='A7vZI3v+Gz7JfuRolKNM4Aff6zaGuT7X0mf3wtoZTnKv6497cVMnhy03KDqX7kBz/q/iidW7srW31oQbBt4VhgoAAACUeyJvcmlnaW4iOiJodHRwczovL3d3dy5nb29nbGUuY29tOjQ0MyIsImZlYXR1cmUiOiJEaXNhYmxlVGhpcmRQYXJ0eVN0b3JhZ2VQYXJ0aXRpb25pbmczIiwiZXhwaXJ5IjoxNzU3OTgwODAwLCJpc1N1YmRvbWFpbiI6dHJ1ZSwiaXNUaGlyZFBhcnR5Ijp0cnVlfQ==';if(v&&v.cookieDeprecationLabel){v.cookieDeprecationLabel.getValue().then(function(l){if(l!=='treatment_1.1'&&l!=='treatment_1.2'&&l!=='control_1.1'){d.head.prepend(m);}});}else{d.head.prepend(m);}po.src='https://www.gstatic.com/recaptcha/releases/A7KpaEASfhDcK0nXxgQEyyYv/recaptcha__en.js';po.crossOrigin='anonymous';po.integrity='sha384-DMJucfgjcmtc4a8x9gFfPgwoWXK+1qozk7K/wTFGVtduYs3wg2BI8Z5lrJXZV+iE';var e=d.querySelector('script[nonce]'),n=e&&(e['nonce']||e.getAttribute('nonce'));if(n){po.setAttribute('nonce',n);}var s=d.getElementsByTagName('script')[0];s.parentNode.insertBefore(po, s);})();
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 581 B |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 44 KiB |