feat: enhance page block system with custom styling support and add TinyMCE rich editor integration
This commit is contained in:
@@ -1,73 +0,0 @@
|
||||
package com.sisvietnamvn.web.config;
|
||||
|
||||
import com.sisvietnamvn.web.domain.Menu;
|
||||
import com.sisvietnamvn.web.domain.MenuItem;
|
||||
import com.sisvietnamvn.web.domain.Page;
|
||||
import com.sisvietnamvn.web.domain.PageLayout;
|
||||
import com.sisvietnamvn.web.repository.MenuItemRepository;
|
||||
import com.sisvietnamvn.web.repository.MenuRepository;
|
||||
import com.sisvietnamvn.web.service.PageService;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class MenuSeeder implements CommandLineRunner {
|
||||
|
||||
private final MenuRepository menuRepository;
|
||||
private final MenuItemRepository menuItemRepository;
|
||||
private final PageService pageService;
|
||||
|
||||
public MenuSeeder(MenuRepository menuRepository, MenuItemRepository menuItemRepository, PageService pageService) {
|
||||
this.menuRepository = menuRepository;
|
||||
this.menuItemRepository = menuItemRepository;
|
||||
this.pageService = pageService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void run(String... args) {
|
||||
Menu primaryMenu = menuRepository.findAll().stream()
|
||||
.filter(m -> m.getName() != null && m.getName().trim().equals("primaryMenu"))
|
||||
.findFirst().orElse(null);
|
||||
|
||||
if (primaryMenu == null) return;
|
||||
|
||||
MenuItem chuyenKhoa = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
|
||||
.filter(i -> i.getLabel() != null && "CHUYÊN KHOA".equalsIgnoreCase(i.getLabel().trim()))
|
||||
.findFirst().orElse(null);
|
||||
|
||||
if (chuyenKhoa == null) return;
|
||||
|
||||
List<Page> specialties = pageService.findAll().stream()
|
||||
.filter(p -> PageLayout.SPECIALTY_DETAIL.equals(p.getLayout()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
int maxOrder = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
|
||||
.filter(i -> chuyenKhoa.equals(i.getParent()))
|
||||
.mapToInt(i -> i.getDisplayOrder() != null ? i.getDisplayOrder() : 0)
|
||||
.max().orElse(0);
|
||||
|
||||
for (Page spec : specialties) {
|
||||
boolean exists = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
|
||||
.filter(i -> chuyenKhoa.equals(i.getParent()))
|
||||
.anyMatch(i -> (i.getLabel() != null && i.getLabel().equalsIgnoreCase(spec.getTitle())) ||
|
||||
(i.getTitle() != null && i.getTitle().equalsIgnoreCase(spec.getTitle())));
|
||||
|
||||
if (!exists) {
|
||||
maxOrder++;
|
||||
MenuItem newItem = new MenuItem();
|
||||
newItem.setMenu(primaryMenu);
|
||||
newItem.setParent(chuyenKhoa);
|
||||
newItem.setLabel(spec.getTitle()); // Set label
|
||||
newItem.setTitle(spec.getTitle()); // Set title as well
|
||||
newItem.setUrl("/chuyen-khoa/" + spec.getSlug());
|
||||
newItem.setDisplayOrder(maxOrder);
|
||||
menuItemRepository.save(newItem);
|
||||
System.out.println("ADDED MENU ITEM: " + spec.getTitle());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.sisvietnamvn.web.config;
|
||||
|
||||
import com.sisvietnamvn.web.domain.Authority;
|
||||
import com.sisvietnamvn.web.repository.AuthorityRepository;
|
||||
import com.sisvietnamvn.web.security.AuthoritiesConstants;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class RoleSeeder implements CommandLineRunner {
|
||||
|
||||
private final AuthorityRepository authorityRepository;
|
||||
|
||||
public RoleSeeder(AuthorityRepository authorityRepository) {
|
||||
this.authorityRepository = authorityRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
List<String> roles = Arrays.asList(
|
||||
AuthoritiesConstants.EDITOR,
|
||||
AuthoritiesConstants.AUTHOR,
|
||||
AuthoritiesConstants.CONTRIBUTOR,
|
||||
AuthoritiesConstants.SUBSCRIBER,
|
||||
AuthoritiesConstants.PRE_AUTH_2FA
|
||||
);
|
||||
|
||||
for (String roleName : roles) {
|
||||
if (authorityRepository.findById(roleName).isEmpty()) {
|
||||
Authority authority = new Authority();
|
||||
authority.setName(roleName);
|
||||
authorityRepository.save(authority);
|
||||
System.out.println("Seeded role: " + roleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package com.sisvietnamvn.web.config;
|
||||
|
||||
import com.sisvietnamvn.web.domain.Page;
|
||||
import com.sisvietnamvn.web.domain.PageLayout;
|
||||
import com.sisvietnamvn.web.domain.PageStatus;
|
||||
import com.sisvietnamvn.web.domain.PageType;
|
||||
import com.sisvietnamvn.web.service.PageService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
public class SeedRunner implements CommandLineRunner {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SeedRunner.class);
|
||||
private final PageService pageService;
|
||||
|
||||
public SeedRunner(PageService pageService) {
|
||||
this.pageService = pageService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
try {
|
||||
Optional<Page> existingPage = pageService.findByPageType(PageType.CONTACT_US);
|
||||
Page page = existingPage.orElseGet(Page::new);
|
||||
LOG.info("Seeding/Updating Contact Us Page...");
|
||||
page.setTitle("Liên hệ");
|
||||
page.setSlug("contact-us");
|
||||
page.setPageType(PageType.CONTACT_US);
|
||||
page.setStatus(PageStatus.PUBLISHED);
|
||||
page.setLayout(PageLayout.STANDARD);
|
||||
|
||||
String content = "{\"time\":1721470000000,\"blocks\":[{\"type\":\"header\",\"data\":{\"text\":\"Cơ sở 1\",\"level\":2}},{\"type\":\"list\",\"data\":{\"style\":\"unordered\",\"items\":[\"Địa chỉ: 215 Hồng Bàng, Phường 11, Quận 5, TP.HCM\",\"Điện thoại: (84.28) 3855 4269\",\"Email: bvdhyd@umc.edu.vn\",\"Website: https://bvdaihoc.com.vn\",\"Map: https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3919.646549247659!2d106.66014561533418!3d10.75539506240683!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x31752eef2f3484f9%3A0xc34ccfa994f1388b!2sUniversity%20Medical%20Center%20HCMC!5e0!3m2!1sen!2s!4v1689304381861!5m2!1sen!2s\"]}},{\"type\":\"header\",\"data\":{\"text\":\"Cơ sở 2\",\"level\":2}},{\"type\":\"list\",\"data\":{\"style\":\"unordered\",\"items\":[\"Địa chỉ: 201 Nguyễn Chí Thanh, Phường 12, Quận 5, TP.HCM\",\"Điện thoại: (84.28) 3955 5548\",\"Email: bvdaihoccoso2@umc.edu.vn\",\"Website: https://bvdaihoccoso2.com.vn\",\"Map: https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3919.6105822604626!2d106.66107411533414!3d10.764472862358826!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x31752efc8f2ba129%3A0x63353bd7e2dbf9e!2zQ8ahIHPhu58gMiBC4buHbmggdmnhu4duIMSQ4bqhaSBo4buNYyBZIETGsOG7o2MgVFAuSENN!5e0!3m2!1sen!2s!4v1689304419810!5m2!1sen!2s\"]}},{\"type\":\"header\",\"data\":{\"text\":\"Cơ sở 3\",\"level\":2}},{\"type\":\"list\",\"data\":{\"style\":\"unordered\",\"items\":[\"Địa chỉ: 221B Hoàng Văn Thụ, Phường 8, Quận Phú Nhuận, TP.HCM\",\"Điện thoại: (84.28) 3842 0070\",\"Email: bvdaihoccoso3@umc.edu.vn\",\"Website: https://bvdaihoccoso3.com.vn\",\"Map: https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3919.1673832863925!2d106.67498701533446!3d10.801646261739345!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x317528dfeb485ec3%3A0x8898124976ea6558!2zQ8ahIHPhu58gMyBC4buHbmggdmnhu4duIMSQ4bqhaSBo4buNYyBZIETGsOG7o2MgVFAuSENN!5e0!3m2!1sen!2s!4v1689304443657!5m2!1sen!2s\"]}},{\"type\":\"header\",\"data\":{\"text\":\"Đơn vị hợp tác\",\"level\":2}},{\"type\":\"list\",\"data\":{\"style\":\"unordered\",\"items\":[\"Địa chỉ: 20-22 Dương Quang Trung, Phường Hòa Hưng, Quận 10, TP.HCM\",\"Điện thoại: 1900 6923\",\"Email: contact.us@umcclinic.com.vn\",\"Website: https://umcclinic.com.vn/\",\"Map: https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3919.4244583196924!2d106.66699101533423!3d10.778768062145718!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x31752ed1807ebf05%3A0xc3f8e53be131ccb4!2sPh%C3%B2ng%20kh%C3%A1m%20B%E1%BB%87nh%20vi%E1%BB%87n%20%C4%90%E1%BA%A1i%20h%E1%BB%8Dc%20Y%20D%C6%B0%E1%BB%A3c%201!5e0!3m2!1sen!2s!4v1689304495719!5m2!1sen!2s\"]}}],\"version\":\"2.29.1\"}";
|
||||
page.setContent(content);
|
||||
|
||||
pageService.save(page);
|
||||
LOG.info("Contact Us Page seeded successfully!");
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to seed Contact Us Page", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package com.sisvietnamvn.web.config;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.domain.Post;
|
||||
import com.sisvietnamvn.web.domain.Tag;
|
||||
import com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderGroup;
|
||||
import com.sisvietnamvn.web.plugins.swiperslider.SwiperSliderAdminController.SliderItem;
|
||||
import com.sisvietnamvn.web.repository.PostRepository;
|
||||
import com.sisvietnamvn.web.repository.TagRepository;
|
||||
import com.sisvietnamvn.web.service.SettingService;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class SliderSeeder implements CommandLineRunner {
|
||||
|
||||
private final PostRepository postRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final SettingService settingService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SliderSeeder(PostRepository postRepository, TagRepository tagRepository, SettingService settingService, ObjectMapper objectMapper) {
|
||||
this.postRepository = postRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.settingService = settingService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) throws Exception {
|
||||
System.out.println("=== STARTING SWIPER SLIDER SEEDER ===");
|
||||
|
||||
Optional<Tag> tagOpt = tagRepository.findByName("Đào tạo");
|
||||
if (tagOpt.isEmpty()) {
|
||||
List<Tag> possible = tagRepository.findByNameContainingIgnoreCase("Đào tạo");
|
||||
if (!possible.isEmpty()) {
|
||||
tagOpt = Optional.of(possible.get(0));
|
||||
System.out.println("Using fallback tag: " + tagOpt.get().getName());
|
||||
}
|
||||
}
|
||||
|
||||
if (tagOpt.isEmpty()) {
|
||||
System.out.println("Tag 'Đào tạo' not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
List<Post> posts = postRepository.findByTagId(tagOpt.get().getId(), PageRequest.of(0, 10));
|
||||
System.out.println("Found " + posts.size() + " posts with tag " + tagOpt.get().getName());
|
||||
|
||||
List<SliderItem> items = posts.stream().map(post -> {
|
||||
String imageUrl = post.getFeaturedImage() != null ? post.getFeaturedImage() : "";
|
||||
String linkUrl = "/dao-tao/" + post.getSlug();
|
||||
return new SliderItem(post.getTitle(), imageUrl, linkUrl, "", null, null, null);
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
SliderGroup newGroup = new SliderGroup(
|
||||
"chuong-trinh-khac",
|
||||
"Chương trình khác",
|
||||
"card",
|
||||
"",
|
||||
"",
|
||||
"Tự động tạo từ thẻ " + tagOpt.get().getName(),
|
||||
items
|
||||
);
|
||||
|
||||
String json = settingService.getValue("plugin_swiper_slider_data", "[]");
|
||||
List<SliderGroup> groups = new ArrayList<>();
|
||||
try {
|
||||
groups = objectMapper.readValue(json, new TypeReference<List<SliderGroup>>() {});
|
||||
} catch (Exception e) {}
|
||||
|
||||
groups.removeIf(g -> "chuong-trinh-khac".equals(g.slug()));
|
||||
groups.add(newGroup);
|
||||
|
||||
String newJson = objectMapper.writeValueAsString(groups);
|
||||
settingService.setValue("plugin_swiper_slider_data", newJson);
|
||||
System.out.println("=== SWIPER SLIDER SEEDED SUCCESSFULLY ===");
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -1,6 +1,5 @@
|
||||
package com.sisvietnamvn.web.controller;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -19,13 +18,13 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.domain.Page;
|
||||
import com.sisvietnamvn.web.domain.PageStatus;
|
||||
import com.sisvietnamvn.web.domain.Post;
|
||||
import com.sisvietnamvn.web.hook.HookManager;
|
||||
import com.sisvietnamvn.web.repository.PostRepository;
|
||||
import com.sisvietnamvn.web.security.AuthoritiesConstants;
|
||||
import com.sisvietnamvn.web.security.SecurityUtils;
|
||||
import com.sisvietnamvn.web.service.PageService;
|
||||
import com.sisvietnamvn.web.domain.Post;
|
||||
import com.sisvietnamvn.web.domain.PageStatus;
|
||||
import com.sisvietnamvn.web.repository.PostRepository;
|
||||
|
||||
/**
|
||||
* Controller for rendering public-facing pages dynamically.
|
||||
@@ -424,6 +423,19 @@ public class PageController {
|
||||
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
|
||||
}
|
||||
}
|
||||
if (block.containsKey("tunes")) {
|
||||
Map<String, Object> tunes = (Map<String, Object>) block.get("tunes");
|
||||
if (tunes != null && tunes.containsKey("textStyling")) {
|
||||
Map<String, Object> textStyling = (Map<String, Object>) tunes.get("textStyling");
|
||||
if (textStyling != null) {
|
||||
block.put("cssClass", textStyling.get("cssClass"));
|
||||
block.put("customStyle", textStyling.get("customStyle"));
|
||||
block.put("alignment", textStyling.get("alignment"));
|
||||
block.put("elementId", textStyling.get("elementId"));
|
||||
block.put("customAttrs", textStyling.get("customAttrs"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (JsonProcessingException e) {
|
||||
|
||||
+29
-2
@@ -4,6 +4,9 @@ import com.sisvietnamvn.web.domain.Media;
|
||||
import com.sisvietnamvn.web.service.MediaService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -71,8 +74,32 @@ public class MediaController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated media list with optional keyword search.
|
||||
* GET /api/manage/media/list?page=0&size=24&keyword=photo
|
||||
* Returns: { content: [...], totalPages, totalElements, number, last }
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<List<Media>> getMediaList() {
|
||||
return ResponseEntity.ok(mediaService.findAll());
|
||||
public ResponseEntity<Map<String, Object>> getMediaList(
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "24") int size,
|
||||
@RequestParam(value = "keyword", required = false) String keyword) {
|
||||
|
||||
log.debug("REST request to list media (page={}, size={}, keyword={})", page, size, keyword);
|
||||
|
||||
// Clamp size to prevent abuse
|
||||
size = Math.min(size, 100);
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
Page<Media> mediaPage = mediaService.findFiltered(null, keyword, pageable);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("content", mediaPage.getContent());
|
||||
result.put("totalPages", mediaPage.getTotalPages());
|
||||
result.put("totalElements", mediaPage.getTotalElements());
|
||||
result.put("number", mediaPage.getNumber());
|
||||
result.put("last", mediaPage.isLast());
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ h6,
|
||||
display: none !important;
|
||||
}
|
||||
.desktop-only {
|
||||
display: block !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -1738,3 +1738,98 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */
|
||||
font-size: 1.05rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
CSS Grid System Override for Grid Block (sis-grid-layout)
|
||||
========================================================================== */
|
||||
.sis-grid-layout {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 1.5rem !important;
|
||||
}
|
||||
|
||||
/* CSS Grid Alignment Support */
|
||||
@media (min-width: 768px) {
|
||||
.sis-grid-layout.justify-content-center {
|
||||
display: flex !important;
|
||||
flex-wrap: nowrap !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.sis-grid-layout.justify-content-center > [class*="sis-grid-span-"],
|
||||
.sis-grid-layout.justify-content-center > [class*="col-"] {
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
.sis-grid-layout.justify-content-center > .sis-grid-span-4,
|
||||
.sis-grid-layout.justify-content-center > .col-md-4 {
|
||||
flex: 0 0 calc(33.333333% - 1rem) !important;
|
||||
width: calc(33.333333% - 1rem) !important;
|
||||
max-width: calc(33.333333% - 1rem) !important;
|
||||
}
|
||||
.sis-grid-layout.justify-content-center > .sis-grid-span-6,
|
||||
.sis-grid-layout.justify-content-center > .col-md-6 {
|
||||
flex: 0 0 calc(50% - 1rem) !important;
|
||||
width: calc(50% - 1rem) !important;
|
||||
max-width: calc(50% - 1rem) !important;
|
||||
}
|
||||
.sis-grid-layout.justify-content-center > .sis-grid-span-3,
|
||||
.sis-grid-layout.justify-content-center > .col-md-3 {
|
||||
flex: 0 0 calc(25% - 1rem) !important;
|
||||
width: calc(25% - 1rem) !important;
|
||||
max-width: calc(25% - 1rem) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sis-grid-layout.justify-content-end {
|
||||
display: flex !important;
|
||||
justify-content: flex-end !important;
|
||||
}
|
||||
.sis-grid-layout.align-items-center {
|
||||
align-items: center !important;
|
||||
}
|
||||
.sis-grid-layout.justify-content-between {
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
.sis-grid-layout.justify-content-around {
|
||||
justify-content: space-around !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.sis-grid-layout {
|
||||
grid-template-columns: repeat(12, 1fr) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback/Mobile-first grid spans (full width on mobile) */
|
||||
.sis-grid-span-1,
|
||||
.sis-grid-span-2,
|
||||
.sis-grid-span-3,
|
||||
.sis-grid-span-4,
|
||||
.sis-grid-span-5,
|
||||
.sis-grid-span-6,
|
||||
.sis-grid-span-7,
|
||||
.sis-grid-span-8,
|
||||
.sis-grid-span-9,
|
||||
.sis-grid-span-10,
|
||||
.sis-grid-span-11,
|
||||
.sis-grid-span-12 {
|
||||
grid-column: span 12 !important;
|
||||
}
|
||||
|
||||
/* Tablet & Desktop grid spans */
|
||||
@media (min-width: 768px) {
|
||||
.sis-grid-span-1 { grid-column: span 1 !important; }
|
||||
.sis-grid-span-2 { grid-column: span 2 !important; }
|
||||
.sis-grid-span-3 { grid-column: span 3 !important; }
|
||||
.sis-grid-span-4 { grid-column: span 4 !important; }
|
||||
.sis-grid-span-5 { grid-column: span 5 !important; }
|
||||
.sis-grid-span-6 { grid-column: span 6 !important; }
|
||||
.sis-grid-span-7 { grid-column: span 7 !important; }
|
||||
.sis-grid-span-8 { grid-column: span 8 !important; }
|
||||
.sis-grid-span-9 { grid-column: span 9 !important; }
|
||||
.sis-grid-span-10 { grid-column: span 10 !important; }
|
||||
.sis-grid-span-11 { grid-column: span 11 !important; }
|
||||
.sis-grid-span-12 { grid-column: span 12 !important; }
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,13 @@ class SISRawHtmlTool {
|
||||
stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)
|
||||
};
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.editorInstance) {
|
||||
this.editorInstance.dispose();
|
||||
this.editorInstance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SISAccordionTool {
|
||||
@@ -525,189 +532,7 @@ class SISYouTubeTool {
|
||||
}
|
||||
}
|
||||
|
||||
class SISHeroBannerTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Hero Banner',
|
||||
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
title: (data && data.title) ? data.title : '',
|
||||
subtitle: (data && data.subtitle) ? data.subtitle : '',
|
||||
bgImage: (data && data.bgImage) ? data.bgImage : '',
|
||||
btnText: (data && data.btnText) ? data.btnText : '',
|
||||
btnLink: (data && data.btnLink) ? data.btnLink : '',
|
||||
height: (data && data.height) ? data.height : '350px',
|
||||
textAlign: (data && data.textAlign) ? data.textAlign : 'center',
|
||||
overlayOpacity: (data && data.overlayOpacity !== undefined) ? data.overlayOpacity : '0.4',
|
||||
stretched: (data && data.stretched !== undefined) ? !!data.stretched : true
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const container = document.createElement('div');
|
||||
container.style.border = '1px solid #e3e6f0';
|
||||
container.style.borderRadius = '8px';
|
||||
container.style.padding = '14px';
|
||||
container.style.background = '#fff';
|
||||
container.style.marginBottom = '12px';
|
||||
|
||||
const headerLabel = document.createElement('label');
|
||||
headerLabel.className = 'font-weight-bold text-primary small mb-2 d-block';
|
||||
headerLabel.innerHTML = '<i class="fas fa-image"></i> Hero Banner Block Settings';
|
||||
container.appendChild(headerLabel);
|
||||
|
||||
// Stretch toggle
|
||||
const stretchWrapper = document.createElement('div');
|
||||
stretchWrapper.className = 'custom-control custom-switch mb-3';
|
||||
const stretchCheck = document.createElement('input');
|
||||
stretchCheck.type = 'checkbox';
|
||||
stretchCheck.className = 'custom-control-input';
|
||||
stretchCheck.id = 'hero_stretch_' + Math.random().toString(36).substring(7);
|
||||
stretchCheck.checked = !!this.data.stretched;
|
||||
|
||||
const stretchLabel = document.createElement('label');
|
||||
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
|
||||
stretchLabel.htmlFor = stretchCheck.id;
|
||||
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch Banner to Full Screen Width';
|
||||
|
||||
stretchCheck.addEventListener('change', () => {
|
||||
this.data.stretched = stretchCheck.checked;
|
||||
});
|
||||
stretchWrapper.appendChild(stretchCheck);
|
||||
stretchWrapper.appendChild(stretchLabel);
|
||||
container.appendChild(stretchWrapper);
|
||||
|
||||
// Inputs
|
||||
this.titleInput = this._createInput('Banner Title', 'e.g. Bệnh Viện Đa Khoa Quốc Tế S.I.S Cần Thơ', this.data.title);
|
||||
this.subtitleInput = this._createInput('Subtitle / Description', 'e.g. Trao niềm tin - Nhận sức khỏe...', this.data.subtitle);
|
||||
this.bgImageInput = this._createInput('Background Image URL', 'https://example.com/hero-banner.jpg', this.data.bgImage);
|
||||
|
||||
// Row for Height & Button Link
|
||||
const configRow = document.createElement('div');
|
||||
configRow.className = 'form-row';
|
||||
|
||||
const heightCol = document.createElement('div');
|
||||
heightCol.className = 'col-md-4 mb-2';
|
||||
this.heightInput = this._createInput('Banner Height (e.g. 350px, 500px, 60vh)', 'e.g. 350px', this.data.height);
|
||||
heightCol.appendChild(this.heightInput);
|
||||
|
||||
const btnCol1 = document.createElement('div');
|
||||
btnCol1.className = 'col-md-4 mb-2';
|
||||
this.btnTextInput = this._createInput('Button Text (Optional)', 'e.g. Đặt Lịch Khám', this.data.btnText);
|
||||
btnCol1.appendChild(this.btnTextInput);
|
||||
|
||||
const btnCol2 = document.createElement('div');
|
||||
btnCol2.className = 'col-md-4 mb-2';
|
||||
this.btnLinkInput = this._createInput('Button Link URL (Optional)', 'e.g. /dat-lich', this.data.btnLink);
|
||||
btnCol2.appendChild(this.btnLinkInput);
|
||||
|
||||
configRow.appendChild(heightCol);
|
||||
configRow.appendChild(btnCol1);
|
||||
configRow.appendChild(btnCol2);
|
||||
|
||||
// Live Preview Box
|
||||
const previewBox = document.createElement('div');
|
||||
previewBox.className = 'hero-banner-preview p-4 rounded text-white my-2';
|
||||
previewBox.style.position = 'relative';
|
||||
previewBox.style.minHeight = this.data.height || '350px';
|
||||
previewBox.style.display = 'flex';
|
||||
previewBox.style.flexDirection = 'column';
|
||||
previewBox.style.justifyContent = 'center';
|
||||
previewBox.style.alignItems = 'center';
|
||||
previewBox.style.textAlign = 'center';
|
||||
previewBox.style.backgroundSize = 'cover';
|
||||
previewBox.style.backgroundPosition = 'center';
|
||||
previewBox.style.overflow = 'hidden';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.right = '0';
|
||||
overlay.style.bottom = '0';
|
||||
overlay.style.background = '#000';
|
||||
overlay.style.zIndex = '1';
|
||||
previewBox.appendChild(overlay);
|
||||
|
||||
const contentBox = document.createElement('div');
|
||||
contentBox.style.position = 'relative';
|
||||
contentBox.style.zIndex = '2';
|
||||
previewBox.appendChild(contentBox);
|
||||
|
||||
const updatePreview = () => {
|
||||
const bg = this.bgImageInput.querySelector('input').value.trim();
|
||||
const title = this.titleInput.querySelector('input').value.trim() || 'Hero Banner Title';
|
||||
const sub = this.subtitleInput.querySelector('input').value.trim();
|
||||
const btnT = this.btnTextInput.querySelector('input').value.trim();
|
||||
const bannerH = this.heightInput.querySelector('input').value.trim() || '350px';
|
||||
|
||||
previewBox.style.minHeight = bannerH;
|
||||
previewBox.style.backgroundImage = bg ? 'url("' + bg + '")' : 'linear-gradient(135deg, #002554, #881C1C)';
|
||||
overlay.style.opacity = this.data.overlayOpacity || '0.4';
|
||||
|
||||
let html = '<h4 class="font-weight-bold mb-1 text-white">' + title + '</h4>';
|
||||
if (sub) html += '<p class="small mb-2 text-light">' + sub + '</p>';
|
||||
if (btnT) html += '<span class="btn btn-sm btn-danger font-weight-bold px-3">' + btnT + '</span>';
|
||||
contentBox.innerHTML = html;
|
||||
};
|
||||
|
||||
[this.titleInput, this.subtitleInput, this.bgImageInput].forEach(wrapper => {
|
||||
const input = wrapper.querySelector('input');
|
||||
input.addEventListener('input', updatePreview);
|
||||
if (this.readOnly) input.disabled = true;
|
||||
container.appendChild(wrapper);
|
||||
});
|
||||
|
||||
[this.heightInput, this.btnTextInput, this.btnLinkInput].forEach(wrapper => {
|
||||
const input = wrapper.querySelector('input');
|
||||
input.addEventListener('input', updatePreview);
|
||||
if (this.readOnly) input.disabled = true;
|
||||
});
|
||||
|
||||
container.appendChild(configRow);
|
||||
container.appendChild(previewBox);
|
||||
updatePreview();
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
_createInput(labelText, placeholder, value) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'form-group mb-2';
|
||||
const lbl = document.createElement('label');
|
||||
lbl.className = 'small font-weight-bold text-secondary mb-1 d-block';
|
||||
lbl.innerText = labelText;
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.className = 'form-control form-control-sm';
|
||||
inp.placeholder = placeholder;
|
||||
inp.value = value || '';
|
||||
wrapper.appendChild(lbl);
|
||||
wrapper.appendChild(inp);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
const stretchCheck = blockContent.querySelector('.custom-control-input');
|
||||
return {
|
||||
title: this.titleInput ? this.titleInput.querySelector('input').value : this.data.title,
|
||||
subtitle: this.subtitleInput ? this.subtitleInput.querySelector('input').value : this.data.subtitle,
|
||||
bgImage: this.bgImageInput ? this.bgImageInput.querySelector('input').value : this.data.bgImage,
|
||||
btnText: this.btnTextInput ? this.btnTextInput.querySelector('input').value : this.data.btnText,
|
||||
btnLink: this.btnLinkInput ? this.btnLinkInput.querySelector('input').value : this.data.btnLink,
|
||||
height: this.heightInput ? this.heightInput.querySelector('input').value : this.data.height,
|
||||
textAlign: this.data.textAlign || 'center',
|
||||
overlayOpacity: this.data.overlayOpacity || '0.4',
|
||||
stretched: stretchCheck ? stretchCheck.checked : !!this.data.stretched
|
||||
};
|
||||
}
|
||||
}
|
||||
// SISHeroBannerTool extracted to /js/manage/editor-plugins/hero-banner.js
|
||||
|
||||
|
||||
|
||||
@@ -733,6 +558,7 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
|
||||
builtInTools.header = {
|
||||
class: Header,
|
||||
inlineToolbar: ['link'],
|
||||
tunes: ['textStyling'],
|
||||
config: {
|
||||
placeholder: 'Header text...',
|
||||
levels: [1, 2, 3, 4, 5, 6],
|
||||
@@ -741,16 +567,22 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
|
||||
};
|
||||
}
|
||||
|
||||
builtInTools.paragraph = {
|
||||
tunes: ['textStyling']
|
||||
};
|
||||
|
||||
if (typeof NestedList !== 'undefined') {
|
||||
builtInTools.list = {
|
||||
class: NestedList,
|
||||
inlineToolbar: true,
|
||||
tunes: ['textStyling'],
|
||||
config: { defaultStyle: 'unordered' }
|
||||
};
|
||||
} else if (typeof List !== 'undefined') {
|
||||
builtInTools.list = {
|
||||
class: List,
|
||||
inlineToolbar: true,
|
||||
tunes: ['textStyling'],
|
||||
config: { defaultStyle: 'unordered' }
|
||||
};
|
||||
}
|
||||
@@ -759,6 +591,7 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
|
||||
builtInTools.quote = {
|
||||
class: Quote,
|
||||
inlineToolbar: true,
|
||||
tunes: ['textStyling'],
|
||||
config: {
|
||||
quotePlaceholder: 'Enter a quote',
|
||||
captionPlaceholder: 'Quote\'s author'
|
||||
@@ -767,31 +600,135 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
|
||||
}
|
||||
|
||||
if (typeof Delimiter !== 'undefined') builtInTools.delimiter = { class: Delimiter };
|
||||
if (typeof Table !== 'undefined') builtInTools.table = { class: Table, inlineToolbar: true, config: { rows: 2, cols: 3 } };
|
||||
if (typeof Table !== 'undefined') {
|
||||
builtInTools.table = {
|
||||
class: Table,
|
||||
inlineToolbar: true,
|
||||
tunes: ['textStyling'],
|
||||
config: { rows: 2, cols: 3 }
|
||||
};
|
||||
}
|
||||
if (typeof CodeTool !== 'undefined') builtInTools.code = { class: CodeTool };
|
||||
if (typeof Warning !== 'undefined') builtInTools.warning = { class: Warning, inlineToolbar: true, config: { titlePlaceholder: 'Title', messagePlaceholder: 'Message' } };
|
||||
if (typeof Marker !== 'undefined') builtInTools.marker = { class: Marker };
|
||||
if (typeof InlineCode !== 'undefined') builtInTools.inlineCode = { class: InlineCode };
|
||||
if (typeof Underline !== 'undefined') builtInTools.underline = { class: Underline };
|
||||
if (typeof ImageTool !== 'undefined') {
|
||||
builtInTools.image = {
|
||||
class: ImageTool,
|
||||
config: {
|
||||
endpoints: {
|
||||
byFile: '/api/manage/media/upload',
|
||||
byUrl: '/api/manage/media/fetchUrl'
|
||||
},
|
||||
uploader: {
|
||||
uploadByUrl(url) {
|
||||
return Promise.resolve({
|
||||
success: 1,
|
||||
file: { url: url }
|
||||
});
|
||||
}
|
||||
},
|
||||
field: 'file',
|
||||
types: 'image/*'
|
||||
// Custom Image Block that bypasses default ImageTool uploader and triggers SISMediaPicker directly
|
||||
class SISCustomImageTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Image',
|
||||
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
file: data.file || { url: '' },
|
||||
caption: data.caption || '',
|
||||
withBorder: !!data.withBorder,
|
||||
stretched: !!data.stretched,
|
||||
withBackground: !!data.withBackground
|
||||
};
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'sis-custom-image-block-wrapper position-relative py-2';
|
||||
|
||||
if (!this.data.file.url) {
|
||||
// Render placeholder / picker launcher
|
||||
const trigger = document.createElement('div');
|
||||
trigger.className = 'border rounded p-4 text-center cursor-pointer text-muted bg-light d-flex flex-column align-items-center justify-content-center';
|
||||
trigger.style.minHeight = '150px';
|
||||
trigger.innerHTML = '<i class="fas fa-images fa-2x mb-2 text-primary"></i><span>Chọn ảnh từ Thư viện (Media Library)</span>';
|
||||
trigger.addEventListener('click', () => this._openPicker());
|
||||
this.container.appendChild(trigger);
|
||||
} else {
|
||||
// Render Image preview along with quick settings button
|
||||
this._renderPreview();
|
||||
}
|
||||
|
||||
return this.container;
|
||||
}
|
||||
|
||||
_openPicker() {
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((url, mediaObj, config) => {
|
||||
if (url) {
|
||||
this.data.file = {
|
||||
url: url,
|
||||
style: config ? config.style : '',
|
||||
cssClass: config ? config.cssClass : '',
|
||||
alt: config ? config.alt : '',
|
||||
customAttributes: config ? config.customAttributes : '',
|
||||
aspectRatio: config ? config.aspectRatio : ''
|
||||
};
|
||||
this.data.caption = (config && config.alt) || '';
|
||||
this._renderPreview();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
alert('SISMediaPicker is not loaded.');
|
||||
}
|
||||
}
|
||||
|
||||
_renderPreview() {
|
||||
if (!this.container) return;
|
||||
this.container.innerHTML = '';
|
||||
|
||||
const imgWrapper = document.createElement('div');
|
||||
imgWrapper.className = 'position-relative text-center d-inline-block w-100';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = this.data.file.url;
|
||||
img.className = 'img-fluid rounded shadow-sm';
|
||||
if (this.data.file.style) img.style.cssText = this.data.file.style;
|
||||
if (this.data.file.cssClass) img.className = this.data.file.cssClass;
|
||||
if (this.data.file.alt) img.alt = this.data.file.alt;
|
||||
|
||||
// Re-configure button
|
||||
if (!this.readOnly) {
|
||||
const settingsBtn = document.createElement('button');
|
||||
settingsBtn.type = 'button';
|
||||
settingsBtn.className = 'btn btn-xs btn-primary shadow-sm position-absolute';
|
||||
settingsBtn.style.cssText = 'top: 10px; right: 10px; z-index: 100; font-size: 11px; padding: 2px 8px; border-radius: 4px; opacity: 0.85;';
|
||||
settingsBtn.innerHTML = '<i class="fas fa-cog mr-1"></i> Quick settings';
|
||||
settingsBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._openPicker();
|
||||
});
|
||||
imgWrapper.appendChild(settingsBtn);
|
||||
}
|
||||
|
||||
imgWrapper.appendChild(img);
|
||||
this.container.appendChild(imgWrapper);
|
||||
|
||||
// Caption input underneath image
|
||||
const captionInput = document.createElement('input');
|
||||
captionInput.type = 'text';
|
||||
captionInput.className = 'form-control form-control-sm text-center border-0 text-muted mt-2';
|
||||
captionInput.placeholder = 'Nhập chú thích hình ảnh (Caption)...';
|
||||
captionInput.value = this.data.caption || '';
|
||||
captionInput.disabled = this.readOnly;
|
||||
captionInput.addEventListener('input', (e) => {
|
||||
this.data.caption = e.target.value;
|
||||
});
|
||||
this.container.appendChild(captionInput);
|
||||
}
|
||||
|
||||
save(block) {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof ImageTool !== 'undefined' || true) {
|
||||
builtInTools.image = {
|
||||
class: SISCustomImageTool
|
||||
};
|
||||
window.SISEditorPlugins.image = builtInTools.image;
|
||||
}
|
||||
@@ -806,14 +743,30 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
|
||||
builtInTools.raw = { class: SISRawHtmlTool };
|
||||
builtInTools.accordion = { class: SISAccordionTool };
|
||||
builtInTools.youtube = { class: SISYouTubeTool };
|
||||
builtInTools.hero = { class: SISHeroBannerTool };
|
||||
if (typeof SISHeroBannerTool !== 'undefined') {
|
||||
builtInTools.hero = { class: SISHeroBannerTool };
|
||||
}
|
||||
if (typeof SISStickyNavTool !== 'undefined') {
|
||||
builtInTools.stickyNav = { class: SISStickyNavTool };
|
||||
}
|
||||
if (typeof TextStylingTune !== 'undefined') {
|
||||
builtInTools.textStyling = { class: TextStylingTune };
|
||||
}
|
||||
|
||||
// === Merge built-in tools with any registered plugins ===
|
||||
var allTools = Object.assign({}, builtInTools, window.SISEditorPlugins);
|
||||
|
||||
// Apply the textStyling tune to all block tools dynamically
|
||||
for (var toolName in allTools) {
|
||||
if (toolName !== 'textStyling') {
|
||||
if (!allTools[toolName].tunes) {
|
||||
allTools[toolName].tunes = ['textStyling'];
|
||||
} else if (!allTools[toolName].tunes.includes('textStyling')) {
|
||||
allTools[toolName].tunes.push('textStyling');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Parse initial data ===
|
||||
var editorData = null;
|
||||
if (initialData && typeof initialData === 'string') {
|
||||
|
||||
@@ -38,6 +38,7 @@ class FlexTool {
|
||||
this.data[`id${i}`] = data[`id${i}`] || '';
|
||||
this.data[`attr${i}`] = data[`attr${i}`] || data[`attributes${i}`] || '';
|
||||
this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto
|
||||
this.data[`caption${i}`] = data[`caption${i}`] || '';
|
||||
}
|
||||
this.wrapper = undefined;
|
||||
}
|
||||
@@ -337,15 +338,104 @@ class FlexTool {
|
||||
headerRow.appendChild(controlsWrapper);
|
||||
colDiv.appendChild(headerRow);
|
||||
colDiv.appendChild(contentDiv);
|
||||
|
||||
// --- Sub-Plugin (Sub-Content) Section ---
|
||||
const subWrapper = document.createElement('div');
|
||||
if (this.data[`subType${i}`] !== 'none') {
|
||||
subWrapper.className = 'mt-3 pt-2 border-top';
|
||||
}
|
||||
|
||||
const subHeader = document.createElement('div');
|
||||
subHeader.className = 'd-flex justify-content-between align-items-center mb-2';
|
||||
|
||||
const subLabel = document.createElement('label');
|
||||
subLabel.className = 'small font-weight-bold text-muted mb-0';
|
||||
subLabel.style.fontSize = '10px';
|
||||
subLabel.innerText = 'SUB-CONTENT / INJECT PLUGIN';
|
||||
subHeader.appendChild(subLabel);
|
||||
|
||||
const subTypeSelect = document.createElement('select');
|
||||
subTypeSelect.className = 'custom-select custom-select-sm';
|
||||
subTypeSelect.style.width = '130px';
|
||||
subTypeSelect.style.fontSize = '10px';
|
||||
if (this.readOnly) subTypeSelect.disabled = true;
|
||||
|
||||
const subTypes = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'caption', label: 'Text Caption' },
|
||||
{ value: 'html', label: 'HTML/Text' },
|
||||
{ value: 'image', label: 'Image' },
|
||||
{ value: 'youtube', label: 'YouTube' },
|
||||
{ value: 'accordion', label: 'Accordion' }
|
||||
];
|
||||
if (window.SISEditorPlugins) {
|
||||
Object.keys(window.SISEditorPlugins).forEach(key => {
|
||||
if (!subTypes.some(t => t.value === key)) {
|
||||
subTypes.push({ value: key, label: `[Plugin] ${key}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subTypes.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.value;
|
||||
opt.innerText = t.label;
|
||||
opt.selected = this.data[`subType${i}`] === t.value;
|
||||
subTypeSelect.appendChild(opt);
|
||||
});
|
||||
subHeader.appendChild(subTypeSelect);
|
||||
subWrapper.appendChild(subHeader);
|
||||
|
||||
const subContentDiv = document.createElement('div');
|
||||
subWrapper.appendChild(subContentDiv);
|
||||
colDiv.appendChild(subWrapper);
|
||||
|
||||
const renderSubInputs = () => {
|
||||
subContentDiv.innerHTML = '';
|
||||
const subType = this.data[`subType${i}`];
|
||||
if (subType === 'none') {
|
||||
subWrapper.className = '';
|
||||
} else {
|
||||
subWrapper.className = 'mt-3 pt-2 border-top';
|
||||
}
|
||||
|
||||
if (subType === 'none') {
|
||||
// Empty
|
||||
} else if (subType === 'caption') {
|
||||
const captionGroup = document.createElement('div');
|
||||
captionGroup.className = 'form-group mb-2';
|
||||
const captionInput = document.createElement('input');
|
||||
captionInput.type = 'text';
|
||||
captionInput.className = 'form-control form-control-sm';
|
||||
captionInput.style.fontSize = '11px';
|
||||
captionInput.placeholder = 'Optional column caption / text...';
|
||||
captionInput.value = this.data[`caption${i}`] || '';
|
||||
if (this.readOnly) captionInput.disabled = true;
|
||||
captionInput.addEventListener('input', (e) => {
|
||||
this.data[`caption${i}`] = e.target.value;
|
||||
});
|
||||
captionGroup.appendChild(captionInput);
|
||||
subContentDiv.appendChild(captionGroup);
|
||||
} else {
|
||||
this._renderTypeSpecificInput(`sub_${i}`, subContentDiv, subType);
|
||||
}
|
||||
};
|
||||
|
||||
subTypeSelect.addEventListener('change', (e) => {
|
||||
this.data[`subType${i}`] = e.target.value;
|
||||
renderSubInputs();
|
||||
});
|
||||
|
||||
row.appendChild(colDiv);
|
||||
|
||||
this._renderTypeSpecificInput(i, contentDiv);
|
||||
renderSubInputs();
|
||||
}
|
||||
|
||||
this.inputsContainer.appendChild(row);
|
||||
}
|
||||
|
||||
_renderTypeSpecificInput(i, container) {
|
||||
_renderTypeSpecificInput(i, container, explicitType) {
|
||||
container.innerHTML = '';
|
||||
|
||||
// Remove tracking of previous instance
|
||||
@@ -357,7 +447,7 @@ class FlexTool {
|
||||
typeContainer.className = 'mb-3';
|
||||
container.appendChild(typeContainer);
|
||||
|
||||
const type = this.data[`type${i}`];
|
||||
const type = explicitType || this.data[`type${i}`] || 'html';
|
||||
|
||||
// Check if type is a dynamic editor plugin registered in window.SISEditorPlugins
|
||||
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
|
||||
@@ -411,11 +501,13 @@ class FlexTool {
|
||||
controls.style.gap = '8px';
|
||||
|
||||
controls.innerHTML = `
|
||||
<div class="text-muted small text-center mb-1"><i class="fas fa-cog"></i> Image Controls</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm w-100" id="btn-upload-img-${i}"><i class="fas fa-upload"></i> Upload Image</button>
|
||||
<button type="button" class="btn btn-outline-info btn-sm w-100" id="btn-fetch-url-${i}"><i class="fas fa-link"></i> Fetch URL</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm w-100" id="btn-media-lib-${i}"><i class="fas fa-images"></i> Media Library</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm w-100 mt-2" id="btn-clear-img-${i}"><i class="fas fa-trash"></i> Remove Image</button>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="text-muted small font-weight-bold"><i class="fas fa-image"></i> Image Settings</span>
|
||||
<button type="button" class="btn btn-link btn-xs text-danger p-0" id="btn-clear-img-${i}" title="Remove Image"><i class="fas fa-trash-alt"></i></button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm w-100 shadow-sm" id="btn-media-lib-${i}">
|
||||
<i class="fas fa-sliders-h mr-1"></i> Quick Settings / Select Image
|
||||
</button>
|
||||
`;
|
||||
|
||||
const uploadBtn = controls.querySelector(`#btn-upload-img-${i}`);
|
||||
@@ -450,16 +542,23 @@ class FlexTool {
|
||||
mediaBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (typeof openMediaPickerModal === 'function') {
|
||||
openMediaPickerModal((url) => {
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((url, mediaObj, config) => {
|
||||
if (url) {
|
||||
this.data[`col${i}`] = {
|
||||
file: { url: url },
|
||||
caption: '',
|
||||
caption: (config && config.alt) || '',
|
||||
withBorder: false,
|
||||
withBackground: false,
|
||||
stretched: false
|
||||
};
|
||||
if (config) {
|
||||
this.data[`imgStyle${i}`] = config.style || '';
|
||||
this.data[`imgClass${i}`] = config.cssClass || '';
|
||||
this.data[`imgAlt${i}`] = config.alt || '';
|
||||
this.data[`imgAttrs${i}`] = config.customAttributes || '';
|
||||
this.data[`imgAspectRatio${i}`] = config.aspectRatio || '';
|
||||
}
|
||||
this._renderTypeSpecificInput(i, container);
|
||||
}
|
||||
});
|
||||
@@ -544,10 +643,17 @@ class FlexTool {
|
||||
chooseBtn.innerText = 'Choose';
|
||||
if (this.readOnly) chooseBtn.disabled = true;
|
||||
chooseBtn.addEventListener('click', () => {
|
||||
if (typeof openMediaPickerModal === 'function') {
|
||||
openMediaPickerModal((selectedUrl) => {
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
|
||||
input.value = selectedUrl;
|
||||
this.data[`imgUrl${i}`] = selectedUrl;
|
||||
if (config) {
|
||||
this.data[`imgStyle${i}`] = config.style || '';
|
||||
this.data[`imgClass${i}`] = config.cssClass || '';
|
||||
this.data[`imgAlt${i}`] = config.alt || '';
|
||||
this.data[`imgAttrs${i}`] = config.customAttributes || '';
|
||||
this.data[`imgAspectRatio${i}`] = config.aspectRatio || '';
|
||||
}
|
||||
previewImg.src = selectedUrl;
|
||||
previewBox.style.display = 'block';
|
||||
});
|
||||
@@ -681,6 +787,8 @@ class FlexTool {
|
||||
typeContainer.appendChild(contentTextarea);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Advanced Settings (ID and Class)
|
||||
const advancedHeader = document.createElement('div');
|
||||
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
|
||||
@@ -760,6 +868,31 @@ class FlexTool {
|
||||
savedData[`id${i}`] = this.data[`id${i}`] || '';
|
||||
savedData[`attr${i}`] = this.data[`attr${i}`] || '';
|
||||
savedData[`width${i}`] = this.data[`width${i}`] || 0;
|
||||
savedData[`caption${i}`] = this.data[`caption${i}`] || '';
|
||||
|
||||
// Sub-plugin serialization
|
||||
const subType = this.data[`subType${i}`] || 'none';
|
||||
savedData[`subType${i}`] = subType;
|
||||
|
||||
if (this.activeInstances[`sub_${i}`]) {
|
||||
try {
|
||||
const subPluginData = this.activeInstances[`sub_${i}`].save();
|
||||
savedData[`colsub_${i}`] = subPluginData;
|
||||
} catch (e) {
|
||||
console.error(`Failed to save sub-plugin ${subType} inside FlexTool:`, e);
|
||||
savedData[`colsub_${i}`] = this.data[`colsub_${i}`];
|
||||
}
|
||||
} else {
|
||||
savedData[`colsub_${i}`] = this.data[`colsub_${i}`] || '';
|
||||
}
|
||||
|
||||
savedData[`imgUrlsub_${i}`] = this.data[`imgUrlsub_${i}`] || '';
|
||||
savedData[`ytUrlsub_${i}`] = this.data[`ytUrlsub_${i}`] || '';
|
||||
savedData[`accTitlesub_${i}`] = this.data[`accTitlesub_${i}`] || '';
|
||||
savedData[`accContentsub_${i}`] = this.data[`accContentsub_${i}`] || '';
|
||||
savedData[`classsub_${i}`] = this.data[`classsub_${i}`] || '';
|
||||
savedData[`idsub_${i}`] = this.data[`idsub_${i}`] || '';
|
||||
savedData[`attrsub_${i}`] = this.data[`attrsub_${i}`] || '';
|
||||
}
|
||||
return savedData;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ class GridTool {
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
cols: parseInt(data.cols) || 2,
|
||||
itemAlignment: data.itemAlignment || data.alignment || (data.centerItems ? 'center' : 'left'),
|
||||
globalStyle: data.globalStyle || '',
|
||||
globalClass: data.globalClass || '',
|
||||
globalId: data.globalId || '',
|
||||
globalAttributes: data.globalAttributes || data.attributes || ''
|
||||
@@ -34,8 +36,20 @@ class GridTool {
|
||||
this.data[`id${i}`] = data[`id${i}`] || '';
|
||||
this.data[`attr${i}`] = data[`attr${i}`] || data[`attributes${i}`] || '';
|
||||
this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto
|
||||
this.data[`caption${i}`] = data[`caption${i}`] || '';
|
||||
|
||||
// Sub-plugin properties
|
||||
this.data[`subType${i}`] = data[`subType${i}`] || 'none';
|
||||
this.data[`colsub_${i}`] = data[`colsub_${i}`] || '';
|
||||
this.data[`imgUrlsub_${i}`] = data[`imgUrlsub_${i}`] || '';
|
||||
this.data[`ytUrlsub_${i}`] = data[`ytUrlsub_${i}`] || '';
|
||||
this.data[`accTitlesub_${i}`] = data[`accTitlesub_${i}`] || '';
|
||||
this.data[`accContentsub_${i}`] = data[`accContentsub_${i}`] || '';
|
||||
this.data[`classsub_${i}`] = data[`classsub_${i}`] || '';
|
||||
this.data[`idsub_${i}`] = data[`idsub_${i}`] || '';
|
||||
this.data[`attrsub_${i}`] = data[`attrsub_${i}`] || '';
|
||||
}
|
||||
this.wrapper = undefined;
|
||||
this.wrapper = undefined;
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -73,14 +87,66 @@ class GridTool {
|
||||
colDiv.appendChild(colInput);
|
||||
settingsRow.appendChild(colDiv);
|
||||
|
||||
// 2. Global CSS Class
|
||||
// 2. Alignment Dropdown option (Left, Center, Right, Space Between, Space Around)
|
||||
const alignDiv = document.createElement('div');
|
||||
alignDiv.className = 'col-md-2 mb-2';
|
||||
const alignLabel = document.createElement('label');
|
||||
alignLabel.className = 'small font-weight-bold text-secondary mb-1';
|
||||
alignLabel.innerText = 'Item Alignment';
|
||||
alignDiv.appendChild(alignLabel);
|
||||
|
||||
const alignSelect = document.createElement('select');
|
||||
alignSelect.className = 'form-control form-control-sm';
|
||||
if (this.readOnly) alignSelect.disabled = true;
|
||||
|
||||
const alignOptions = [
|
||||
{ value: 'left', label: 'Left (default)' },
|
||||
{ value: 'center', label: 'Center' },
|
||||
{ value: 'right', label: 'Right' },
|
||||
{ value: 'between', label: 'Space Between' },
|
||||
{ value: 'around', label: 'Space Around' }
|
||||
];
|
||||
|
||||
alignOptions.forEach(opt => {
|
||||
const optionEl = document.createElement('option');
|
||||
optionEl.value = opt.value;
|
||||
optionEl.innerText = opt.label;
|
||||
if (this.data.itemAlignment === opt.value) {
|
||||
optionEl.selected = true;
|
||||
}
|
||||
alignSelect.appendChild(optionEl);
|
||||
});
|
||||
|
||||
alignSelect.addEventListener('change', (e) => {
|
||||
this.data.itemAlignment = e.target.value;
|
||||
});
|
||||
|
||||
alignDiv.appendChild(alignSelect);
|
||||
settingsRow.appendChild(alignDiv);
|
||||
|
||||
// 3. Dedicated Style Attribute
|
||||
const styleDiv = document.createElement('div');
|
||||
styleDiv.className = 'col-md-3 mb-2';
|
||||
styleDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-paint-brush"></i> Dedicated Style</label>';
|
||||
const styleInput = document.createElement('input');
|
||||
styleInput.type = 'text';
|
||||
styleInput.className = 'form-control form-control-sm';
|
||||
styleInput.placeholder = 'e.g. background: #f8f9fa; padding: 20px;';
|
||||
styleInput.value = this.data.globalStyle || '';
|
||||
if (this.readOnly) styleInput.disabled = true;
|
||||
styleInput.addEventListener('input', (e) => this.data.globalStyle = e.target.value.trim());
|
||||
this.globalStyleInput = styleInput;
|
||||
styleDiv.appendChild(styleInput);
|
||||
settingsRow.appendChild(styleDiv);
|
||||
|
||||
// 4. Global CSS Class
|
||||
const classDiv = document.createElement('div');
|
||||
classDiv.className = 'col-md-3 mb-2';
|
||||
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid CSS Class(es)</label>';
|
||||
classDiv.className = 'col-md-2 mb-2';
|
||||
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid CSS Class</label>';
|
||||
const classInput = document.createElement('input');
|
||||
classInput.type = 'text';
|
||||
classInput.className = 'form-control form-control-sm';
|
||||
classInput.placeholder = 'e.g. my-custom-grid';
|
||||
classInput.placeholder = 'e.g. my-grid';
|
||||
classInput.value = this.data.globalClass || '';
|
||||
if (this.readOnly) classInput.disabled = true;
|
||||
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
|
||||
@@ -88,14 +154,14 @@ class GridTool {
|
||||
classDiv.appendChild(classInput);
|
||||
settingsRow.appendChild(classDiv);
|
||||
|
||||
// 3. Global HTML ID
|
||||
// 5. Global HTML ID
|
||||
const idDiv = document.createElement('div');
|
||||
idDiv.className = 'col-md-3 mb-2';
|
||||
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid HTML ID</label>';
|
||||
const idInput = document.createElement('input');
|
||||
idInput.type = 'text';
|
||||
idInput.className = 'form-control form-control-sm';
|
||||
idInput.placeholder = 'e.g. grid-section-1';
|
||||
idInput.placeholder = 'e.g. grid-1';
|
||||
idInput.value = this.data.globalId || '';
|
||||
if (this.readOnly) idInput.disabled = true;
|
||||
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
|
||||
@@ -103,14 +169,14 @@ class GridTool {
|
||||
idDiv.appendChild(idInput);
|
||||
settingsRow.appendChild(idDiv);
|
||||
|
||||
// 4. Global Custom Attributes
|
||||
// 6. Global Custom Attributes
|
||||
const attrDiv = document.createElement('div');
|
||||
attrDiv.className = 'col-md-4 mb-2';
|
||||
attrDiv.className = 'col-md-12 mb-2';
|
||||
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Custom Attributes</label>';
|
||||
const attrInput = document.createElement('input');
|
||||
attrInput.type = 'text';
|
||||
attrInput.className = 'form-control form-control-sm';
|
||||
attrInput.placeholder = 'e.g. data-aos="fade-up" style="background:#fff"';
|
||||
attrInput.placeholder = 'e.g. data-aos="fade-up"';
|
||||
attrInput.value = this.data.globalAttributes || '';
|
||||
if (this.readOnly) attrInput.disabled = true;
|
||||
attrInput.addEventListener('input', (e) => this.data.globalAttributes = e.target.value.trim());
|
||||
@@ -133,7 +199,7 @@ class GridTool {
|
||||
this.inputsContainer.innerHTML = '';
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
row.className = 'row d-flex flex-wrap';
|
||||
|
||||
const count = this.data.cols;
|
||||
const colWidthClass = count === 1 ? 'col-12' : (count === 2 ? 'col-md-6' : (count === 3 ? 'col-md-4' : (count === 4 ? 'col-md-3' : 'col-md')));
|
||||
@@ -233,15 +299,104 @@ class GridTool {
|
||||
headerRow.appendChild(controlsWrapper);
|
||||
colDiv.appendChild(headerRow);
|
||||
colDiv.appendChild(contentDiv);
|
||||
|
||||
// --- Sub-Plugin (Sub-Content) Section ---
|
||||
const subWrapper = document.createElement('div');
|
||||
if (this.data[`subType${i}`] !== 'none') {
|
||||
subWrapper.className = 'mt-3 pt-2 border-top';
|
||||
}
|
||||
|
||||
const subHeader = document.createElement('div');
|
||||
subHeader.className = 'd-flex justify-content-between align-items-center mb-2';
|
||||
|
||||
const subLabel = document.createElement('label');
|
||||
subLabel.className = 'small font-weight-bold text-muted mb-0';
|
||||
subLabel.style.fontSize = '10px';
|
||||
subLabel.innerText = 'SUB-CONTENT / INJECT PLUGIN';
|
||||
subHeader.appendChild(subLabel);
|
||||
|
||||
const subTypeSelect = document.createElement('select');
|
||||
subTypeSelect.className = 'custom-select custom-select-sm';
|
||||
subTypeSelect.style.width = '130px';
|
||||
subTypeSelect.style.fontSize = '10px';
|
||||
if (this.readOnly) subTypeSelect.disabled = true;
|
||||
|
||||
const subTypes = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'caption', label: 'Text Caption' },
|
||||
{ value: 'html', label: 'HTML/Text' },
|
||||
{ value: 'image', label: 'Image' },
|
||||
{ value: 'youtube', label: 'YouTube' },
|
||||
{ value: 'accordion', label: 'Accordion' }
|
||||
];
|
||||
if (window.SISEditorPlugins) {
|
||||
Object.keys(window.SISEditorPlugins).forEach(key => {
|
||||
if (!subTypes.some(t => t.value === key)) {
|
||||
subTypes.push({ value: key, label: `[Plugin] ${key}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subTypes.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.value;
|
||||
opt.innerText = t.label;
|
||||
opt.selected = this.data[`subType${i}`] === t.value;
|
||||
subTypeSelect.appendChild(opt);
|
||||
});
|
||||
subHeader.appendChild(subTypeSelect);
|
||||
subWrapper.appendChild(subHeader);
|
||||
|
||||
const subContentDiv = document.createElement('div');
|
||||
subWrapper.appendChild(subContentDiv);
|
||||
colDiv.appendChild(subWrapper);
|
||||
|
||||
const renderSubInputs = () => {
|
||||
subContentDiv.innerHTML = '';
|
||||
const subType = this.data[`subType${i}`];
|
||||
if (subType === 'none') {
|
||||
subWrapper.className = '';
|
||||
} else {
|
||||
subWrapper.className = 'mt-3 pt-2 border-top';
|
||||
}
|
||||
|
||||
if (subType === 'none') {
|
||||
// Empty
|
||||
} else if (subType === 'caption') {
|
||||
const captionGroup = document.createElement('div');
|
||||
captionGroup.className = 'form-group mb-2';
|
||||
const captionInput = document.createElement('input');
|
||||
captionInput.type = 'text';
|
||||
captionInput.className = 'form-control form-control-sm';
|
||||
captionInput.style.fontSize = '11px';
|
||||
captionInput.placeholder = 'Optional column caption / text...';
|
||||
captionInput.value = this.data[`caption${i}`] || '';
|
||||
if (this.readOnly) captionInput.disabled = true;
|
||||
captionInput.addEventListener('input', (e) => {
|
||||
this.data[`caption${i}`] = e.target.value;
|
||||
});
|
||||
captionGroup.appendChild(captionInput);
|
||||
subContentDiv.appendChild(captionGroup);
|
||||
} else {
|
||||
this._renderTypeSpecificInput(`sub_${i}`, subContentDiv, subType);
|
||||
}
|
||||
};
|
||||
|
||||
subTypeSelect.addEventListener('change', (e) => {
|
||||
this.data[`subType${i}`] = e.target.value;
|
||||
renderSubInputs();
|
||||
});
|
||||
|
||||
row.appendChild(colDiv);
|
||||
|
||||
this._renderTypeSpecificInput(i, contentDiv);
|
||||
renderSubInputs();
|
||||
}
|
||||
|
||||
this.inputsContainer.appendChild(row);
|
||||
}
|
||||
|
||||
_renderTypeSpecificInput(i, container) {
|
||||
_renderTypeSpecificInput(i, container, explicitType) {
|
||||
container.innerHTML = '';
|
||||
|
||||
// Remove tracking of previous instance
|
||||
@@ -253,7 +408,7 @@ class GridTool {
|
||||
typeContainer.className = 'mb-3';
|
||||
container.appendChild(typeContainer);
|
||||
|
||||
const type = this.data[`type${i}`];
|
||||
const type = explicitType || this.data[`type${i}`] || 'html';
|
||||
|
||||
// Check if type is a dynamic editor plugin registered in window.SISEditorPlugins
|
||||
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
|
||||
@@ -307,11 +462,13 @@ class GridTool {
|
||||
controls.style.gap = '8px';
|
||||
|
||||
controls.innerHTML = `
|
||||
<div class="text-muted small text-center mb-1"><i class="fas fa-cog"></i> Image Controls</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm w-100" id="btn-upload-img-${i}"><i class="fas fa-upload"></i> Upload Image</button>
|
||||
<button type="button" class="btn btn-outline-info btn-sm w-100" id="btn-fetch-url-${i}"><i class="fas fa-link"></i> Fetch URL</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm w-100" id="btn-media-lib-${i}"><i class="fas fa-images"></i> Media Library</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm w-100 mt-2" id="btn-clear-img-${i}"><i class="fas fa-trash"></i> Remove Image</button>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="text-muted small font-weight-bold"><i class="fas fa-image"></i> Image Settings</span>
|
||||
<button type="button" class="btn btn-link btn-xs text-danger p-0" id="btn-clear-img-${i}" title="Remove Image"><i class="fas fa-trash-alt"></i></button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm w-100 shadow-sm" id="btn-media-lib-${i}">
|
||||
<i class="fas fa-sliders-h mr-1"></i> Quick Settings / Select Image
|
||||
</button>
|
||||
`;
|
||||
|
||||
const uploadBtn = controls.querySelector(`#btn-upload-img-${i}`);
|
||||
@@ -346,16 +503,23 @@ class GridTool {
|
||||
mediaBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (typeof openMediaPickerModal === 'function') {
|
||||
openMediaPickerModal((url) => {
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((url, mediaObj, config) => {
|
||||
if (url) {
|
||||
this.data[`col${i}`] = {
|
||||
file: { url: url },
|
||||
caption: '',
|
||||
caption: (config && config.alt) || '',
|
||||
withBorder: false,
|
||||
withBackground: false,
|
||||
stretched: false
|
||||
};
|
||||
if (config) {
|
||||
this.data[`imgStyle${i}`] = config.style || '';
|
||||
this.data[`imgClass${i}`] = config.cssClass || '';
|
||||
this.data[`imgAlt${i}`] = config.alt || '';
|
||||
this.data[`imgAttrs${i}`] = config.customAttributes || '';
|
||||
this.data[`imgAspectRatio${i}`] = config.aspectRatio || '';
|
||||
}
|
||||
this._renderTypeSpecificInput(i, container);
|
||||
}
|
||||
});
|
||||
@@ -440,10 +604,17 @@ class GridTool {
|
||||
chooseBtn.innerText = 'Choose';
|
||||
if (this.readOnly) chooseBtn.disabled = true;
|
||||
chooseBtn.addEventListener('click', () => {
|
||||
if (typeof openMediaPickerModal === 'function') {
|
||||
openMediaPickerModal((selectedUrl) => {
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
|
||||
input.value = selectedUrl;
|
||||
this.data[`imgUrl${i}`] = selectedUrl;
|
||||
if (config) {
|
||||
this.data[`imgStyle${i}`] = config.style || '';
|
||||
this.data[`imgClass${i}`] = config.cssClass || '';
|
||||
this.data[`imgAlt${i}`] = config.alt || '';
|
||||
this.data[`imgAttrs${i}`] = config.customAttributes || '';
|
||||
this.data[`imgAspectRatio${i}`] = config.aspectRatio || '';
|
||||
}
|
||||
previewImg.src = selectedUrl;
|
||||
previewBox.style.display = 'block';
|
||||
});
|
||||
@@ -577,6 +748,8 @@ class GridTool {
|
||||
typeContainer.appendChild(contentTextarea);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Advanced Settings (ID and Class)
|
||||
const advancedHeader = document.createElement('div');
|
||||
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
|
||||
@@ -623,6 +796,8 @@ class GridTool {
|
||||
save(blockContent) {
|
||||
const savedData = {
|
||||
cols: this.data.cols,
|
||||
itemAlignment: this.data.itemAlignment || 'left',
|
||||
globalStyle: this.globalStyleInput ? this.globalStyleInput.value.trim() : (this.data.globalStyle || ''),
|
||||
globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || ''),
|
||||
globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''),
|
||||
globalAttributes: this.globalAttrInput ? this.globalAttrInput.value.trim() : (this.data.globalAttributes || '')
|
||||
@@ -652,6 +827,31 @@ class GridTool {
|
||||
savedData[`id${i}`] = this.data[`id${i}`] || '';
|
||||
savedData[`attr${i}`] = this.data[`attr${i}`] || '';
|
||||
savedData[`width${i}`] = this.data[`width${i}`] || 0;
|
||||
savedData[`caption${i}`] = this.data[`caption${i}`] || '';
|
||||
|
||||
// Sub-plugin serialization
|
||||
const subType = this.data[`subType${i}`] || 'none';
|
||||
savedData[`subType${i}`] = subType;
|
||||
|
||||
if (this.activeInstances[`sub_${i}`]) {
|
||||
try {
|
||||
const subPluginData = this.activeInstances[`sub_${i}`].save();
|
||||
savedData[`colsub_${i}`] = subPluginData;
|
||||
} catch (e) {
|
||||
console.error(`Failed to save sub-plugin ${subType} inside GridTool:`, e);
|
||||
savedData[`colsub_${i}`] = this.data[`colsub_${i}`];
|
||||
}
|
||||
} else {
|
||||
savedData[`colsub_${i}`] = this.data[`colsub_${i}`] || '';
|
||||
}
|
||||
|
||||
savedData[`imgUrlsub_${i}`] = this.data[`imgUrlsub_${i}`] || '';
|
||||
savedData[`ytUrlsub_${i}`] = this.data[`ytUrlsub_${i}`] || '';
|
||||
savedData[`accTitlesub_${i}`] = this.data[`accTitlesub_${i}`] || '';
|
||||
savedData[`accContentsub_${i}`] = this.data[`accContentsub_${i}`] || '';
|
||||
savedData[`classsub_${i}`] = this.data[`classsub_${i}`] || '';
|
||||
savedData[`idsub_${i}`] = this.data[`idsub_${i}`] || '';
|
||||
savedData[`attrsub_${i}`] = this.data[`attrsub_${i}`] || '';
|
||||
}
|
||||
return savedData;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* SISHeroBannerTool — Hero Banner Plugin for Editor.js
|
||||
* Allows content managers to build dynamic hero banners with live preview.
|
||||
*/
|
||||
class SISHeroBannerTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Hero Banner',
|
||||
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
title: (data && data.title) ? data.title : '',
|
||||
subtitle: (data && data.subtitle) ? data.subtitle : '',
|
||||
bgImage: (data && data.bgImage) ? data.bgImage : '',
|
||||
btnText: (data && data.btnText) ? data.btnText : '',
|
||||
btnLink: (data && data.btnLink) ? data.btnLink : '',
|
||||
height: (data && data.height) ? data.height : '350px',
|
||||
textAlign: (data && data.textAlign) ? data.textAlign : 'center',
|
||||
overlayOpacity: (data && data.overlayOpacity !== undefined) ? data.overlayOpacity : '0.4',
|
||||
stretched: (data && data.stretched !== undefined) ? !!data.stretched : true
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const container = document.createElement('div');
|
||||
container.style.border = '1px solid #e3e6f0';
|
||||
container.style.borderRadius = '8px';
|
||||
container.style.padding = '14px';
|
||||
container.style.background = '#fff';
|
||||
container.style.marginBottom = '12px';
|
||||
|
||||
const headerLabel = document.createElement('label');
|
||||
headerLabel.className = 'font-weight-bold text-primary small mb-2 d-block';
|
||||
headerLabel.innerHTML = '<i class="fas fa-image"></i> Hero Banner Block Settings';
|
||||
container.appendChild(headerLabel);
|
||||
|
||||
// Stretch toggle
|
||||
const stretchWrapper = document.createElement('div');
|
||||
stretchWrapper.className = 'custom-control custom-switch mb-3';
|
||||
const stretchCheck = document.createElement('input');
|
||||
stretchCheck.type = 'checkbox';
|
||||
stretchCheck.className = 'custom-control-input';
|
||||
stretchCheck.id = 'hero_stretch_' + Math.random().toString(36).substring(7);
|
||||
stretchCheck.checked = !!this.data.stretched;
|
||||
|
||||
const stretchLabel = document.createElement('label');
|
||||
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
|
||||
stretchLabel.htmlFor = stretchCheck.id;
|
||||
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch Banner to Full Screen Width';
|
||||
|
||||
stretchCheck.addEventListener('change', () => {
|
||||
this.data.stretched = stretchCheck.checked;
|
||||
});
|
||||
stretchWrapper.appendChild(stretchCheck);
|
||||
stretchWrapper.appendChild(stretchLabel);
|
||||
container.appendChild(stretchWrapper);
|
||||
|
||||
// Inputs
|
||||
this.titleInput = this._createInput('Banner Title', 'e.g. Bệnh Viện Đa Khoa Quốc Tế S.I.S Cần Thơ', this.data.title);
|
||||
this.subtitleInput = this._createInput('Subtitle / Description', 'e.g. Trao niềm tin - Nhận sức khỏe...', this.data.subtitle);
|
||||
this.bgImageInput = this._createImageInput('Background Image URL', 'https://example.com/hero-banner.jpg', this.data.bgImage);
|
||||
|
||||
// Row for Height & Button Link
|
||||
const configRow = document.createElement('div');
|
||||
configRow.className = 'form-row';
|
||||
|
||||
const heightCol = document.createElement('div');
|
||||
heightCol.className = 'col-md-4 mb-2';
|
||||
this.heightInput = this._createInput('Banner Height (e.g. 350px, 500px, 60vh)', 'e.g. 350px', this.data.height);
|
||||
heightCol.appendChild(this.heightInput);
|
||||
|
||||
const btnCol1 = document.createElement('div');
|
||||
btnCol1.className = 'col-md-4 mb-2';
|
||||
this.btnTextInput = this._createInput('Button Text (Optional)', 'e.g. Đặt Lịch Khám', this.data.btnText);
|
||||
btnCol1.appendChild(this.btnTextInput);
|
||||
|
||||
const btnCol2 = document.createElement('div');
|
||||
btnCol2.className = 'col-md-4 mb-2';
|
||||
this.btnLinkInput = this._createInput('Button Link URL (Optional)', 'e.g. /dat-lich', this.data.btnLink);
|
||||
btnCol2.appendChild(this.btnLinkInput);
|
||||
|
||||
configRow.appendChild(heightCol);
|
||||
configRow.appendChild(btnCol1);
|
||||
configRow.appendChild(btnCol2);
|
||||
|
||||
// Live Preview Box
|
||||
const previewBox = document.createElement('div');
|
||||
previewBox.className = 'hero-banner-preview p-4 rounded text-white my-2';
|
||||
previewBox.style.position = 'relative';
|
||||
previewBox.style.minHeight = this.data.height || '350px';
|
||||
previewBox.style.display = 'flex';
|
||||
previewBox.style.flexDirection = 'column';
|
||||
previewBox.style.justifyContent = 'center';
|
||||
previewBox.style.alignItems = 'center';
|
||||
previewBox.style.textAlign = 'center';
|
||||
previewBox.style.backgroundSize = 'cover';
|
||||
previewBox.style.backgroundPosition = 'center';
|
||||
previewBox.style.overflow = 'hidden';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.right = '0';
|
||||
overlay.style.bottom = '0';
|
||||
overlay.style.background = '#000';
|
||||
overlay.style.zIndex = '1';
|
||||
previewBox.appendChild(overlay);
|
||||
|
||||
const contentBox = document.createElement('div');
|
||||
contentBox.style.position = 'relative';
|
||||
contentBox.style.zIndex = '2';
|
||||
previewBox.appendChild(contentBox);
|
||||
|
||||
const updatePreview = () => {
|
||||
const bg = this.bgImageInput.querySelector('input').value.trim();
|
||||
const title = this.titleInput.querySelector('input').value.trim() || 'Hero Banner Title';
|
||||
const sub = this.subtitleInput.querySelector('input').value.trim();
|
||||
const btnT = this.btnTextInput.querySelector('input').value.trim();
|
||||
const bannerH = this.heightInput.querySelector('input').value.trim() || '350px';
|
||||
|
||||
previewBox.style.minHeight = bannerH;
|
||||
previewBox.style.backgroundImage = bg ? 'url("' + bg + '")' : 'linear-gradient(135deg, #002554, #881C1C)';
|
||||
overlay.style.opacity = this.data.overlayOpacity || '0.4';
|
||||
|
||||
let html = '<h4 class="font-weight-bold mb-1 text-white">' + title + '</h4>';
|
||||
if (sub) html += '<p class="small mb-2 text-light">' + sub + '</p>';
|
||||
if (btnT) html += '<span class="btn btn-sm btn-danger font-weight-bold px-3">' + btnT + '</span>';
|
||||
contentBox.innerHTML = html;
|
||||
};
|
||||
|
||||
[this.titleInput, this.subtitleInput, this.bgImageInput].forEach(wrapper => {
|
||||
const input = wrapper.querySelector('input');
|
||||
input.addEventListener('input', updatePreview);
|
||||
if (this.readOnly) input.disabled = true;
|
||||
container.appendChild(wrapper);
|
||||
});
|
||||
|
||||
[this.heightInput, this.btnTextInput, this.btnLinkInput].forEach(wrapper => {
|
||||
const input = wrapper.querySelector('input');
|
||||
input.addEventListener('input', updatePreview);
|
||||
if (this.readOnly) input.disabled = true;
|
||||
});
|
||||
|
||||
container.appendChild(configRow);
|
||||
container.appendChild(previewBox);
|
||||
updatePreview();
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
_createInput(labelText, placeholder, value) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'form-group mb-2';
|
||||
const lbl = document.createElement('label');
|
||||
lbl.className = 'small font-weight-bold text-secondary mb-1 d-block';
|
||||
lbl.innerText = labelText;
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.className = 'form-control form-control-sm';
|
||||
inp.placeholder = placeholder;
|
||||
inp.value = value || '';
|
||||
wrapper.appendChild(lbl);
|
||||
wrapper.appendChild(inp);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
_createImageInput(labelText, placeholder, value) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'form-group mb-2';
|
||||
|
||||
const lbl = document.createElement('label');
|
||||
lbl.className = 'small font-weight-bold text-secondary mb-1 d-block';
|
||||
lbl.innerText = labelText;
|
||||
wrapper.appendChild(lbl);
|
||||
|
||||
const inputGroup = document.createElement('div');
|
||||
inputGroup.className = 'input-group input-group-sm';
|
||||
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.className = 'form-control';
|
||||
inp.placeholder = placeholder;
|
||||
inp.value = value || '';
|
||||
if (this.readOnly) inp.disabled = true;
|
||||
inputGroup.appendChild(inp);
|
||||
|
||||
if (!this.readOnly) {
|
||||
const appendDiv = document.createElement('div');
|
||||
appendDiv.className = 'input-group-append';
|
||||
|
||||
const mediaBtn = document.createElement('button');
|
||||
mediaBtn.type = 'button';
|
||||
mediaBtn.className = 'btn btn-outline-info';
|
||||
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
|
||||
mediaBtn.addEventListener('click', () => {
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
|
||||
inp.value = selectedUrl;
|
||||
if (config) {
|
||||
this._bgImageConfig = config;
|
||||
}
|
||||
// Dispatch input event to trigger preview update
|
||||
inp.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
} else {
|
||||
alert('Media modal function openSISMediaModal is not available.');
|
||||
}
|
||||
});
|
||||
appendDiv.appendChild(mediaBtn);
|
||||
inputGroup.appendChild(appendDiv);
|
||||
}
|
||||
|
||||
wrapper.appendChild(inputGroup);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
const stretchCheck = blockContent.querySelector('.custom-control-input');
|
||||
return {
|
||||
title: this.titleInput ? this.titleInput.querySelector('input').value : this.data.title,
|
||||
subtitle: this.subtitleInput ? this.subtitleInput.querySelector('input').value : this.data.subtitle,
|
||||
bgImage: this.bgImageInput ? this.bgImageInput.querySelector('input').value : this.data.bgImage,
|
||||
bgImageStyle: (this._bgImageConfig && this._bgImageConfig.style) || this.data.bgImageStyle || '',
|
||||
bgImageClass: (this._bgImageConfig && this._bgImageConfig.cssClass) || this.data.bgImageClass || '',
|
||||
bgImageAlt: (this._bgImageConfig && this._bgImageConfig.alt) || this.data.bgImageAlt || '',
|
||||
bgImageAttrs: (this._bgImageConfig && this._bgImageConfig.customAttributes) || this.data.bgImageAttrs || '',
|
||||
bgImageAspectRatio: (this._bgImageConfig && this._bgImageConfig.aspectRatio) || this.data.bgImageAspectRatio || '',
|
||||
btnText: this.btnTextInput ? this.btnTextInput.querySelector('input').value : this.data.btnText,
|
||||
btnLink: this.btnLinkInput ? this.btnLinkInput.querySelector('input').value : this.data.btnLink,
|
||||
height: this.heightInput ? this.heightInput.querySelector('input').value : this.data.height,
|
||||
textAlign: this.data.textAlign || 'center',
|
||||
overlayOpacity: this.data.overlayOpacity || '0.4',
|
||||
stretched: stretchCheck ? stretchCheck.checked : !!this.data.stretched
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Register plugin globally
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['hero'] = { class: SISHeroBannerTool };
|
||||
@@ -315,10 +315,17 @@ class SISHoverCardTool {
|
||||
mediaBtn.className = 'btn btn-outline-info';
|
||||
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
|
||||
mediaBtn.addEventListener('click', () => {
|
||||
if (typeof window.openSISMediaModal === 'function') {
|
||||
window.openSISMediaModal((selectedUrl) => {
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
|
||||
imgInput.value = selectedUrl;
|
||||
item.imageUrl = selectedUrl;
|
||||
if (config) {
|
||||
item.imageStyle = config.style || '';
|
||||
item.imageClass = config.cssClass || '';
|
||||
item.imageAlt = config.alt || '';
|
||||
item.imageAttrs = config.customAttributes || '';
|
||||
item.imageAspectRatio = config.aspectRatio || '';
|
||||
}
|
||||
updatePreview(selectedUrl);
|
||||
});
|
||||
} else {
|
||||
@@ -380,10 +387,17 @@ class SISHoverCardTool {
|
||||
mediaBtn.className = 'btn btn-outline-info';
|
||||
mediaBtn.innerHTML = '<i class="fas fa-images"></i> Media Library';
|
||||
mediaBtn.addEventListener('click', () => {
|
||||
if (typeof window.openSISMediaModal === 'function') {
|
||||
window.openSISMediaModal((selectedUrl) => {
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
|
||||
hoverImgInput.value = selectedUrl;
|
||||
item.hoverImageUrl = selectedUrl;
|
||||
if (config) {
|
||||
item.hoverImageStyle = config.style || '';
|
||||
item.hoverImageClass = config.cssClass || '';
|
||||
item.hoverImageAlt = config.alt || '';
|
||||
item.hoverImageAttrs = config.customAttributes || '';
|
||||
item.hoverImageAspectRatio = config.aspectRatio || '';
|
||||
}
|
||||
updateHoverPreview(selectedUrl);
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* TextStylingTune — Block Tune for Editor.js
|
||||
* Adds alignment, custom CSS class, custom CSS style, and HTML ID settings to blocks.
|
||||
*/
|
||||
class TextStylingTune {
|
||||
constructor({ api, block, data }) {
|
||||
this.api = api;
|
||||
this.block = block;
|
||||
this.data = data || {
|
||||
alignment: '',
|
||||
cssClass: '',
|
||||
customStyle: '',
|
||||
elementId: '',
|
||||
customAttrs: ''
|
||||
};
|
||||
}
|
||||
|
||||
static get isTune() {
|
||||
return true;
|
||||
}
|
||||
|
||||
render() {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'cdx-settings-text-styling p-2 border-top';
|
||||
wrapper.style.fontFamily = 'inherit';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'small font-weight-bold text-primary mb-2';
|
||||
title.innerHTML = '<i class="fas fa-palette"></i> Style & Alignment';
|
||||
wrapper.appendChild(title);
|
||||
|
||||
// Alignment row
|
||||
const alignWrapper = document.createElement('div');
|
||||
alignWrapper.className = 'd-flex mb-2 align-items-center justify-content-start';
|
||||
|
||||
const alignments = [
|
||||
{ name: 'left', icon: '<i class="fas fa-align-left"></i>', title: 'Align Left' },
|
||||
{ name: 'center', icon: '<i class="fas fa-align-center"></i>', title: 'Align Center' },
|
||||
{ name: 'right', icon: '<i class="fas fa-align-right"></i>', title: 'Align Right' },
|
||||
{ name: 'justify', icon: '<i class="fas fa-align-justify"></i>', title: 'Align Justify' }
|
||||
];
|
||||
|
||||
alignments.forEach(align => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-xs btn-light mr-1 border';
|
||||
btn.style.width = '30px';
|
||||
btn.style.height = '30px';
|
||||
btn.style.padding = '0';
|
||||
btn.title = align.title;
|
||||
if (this.data.alignment === align.name) {
|
||||
btn.className = 'btn btn-xs btn-info mr-1 border';
|
||||
}
|
||||
btn.innerHTML = align.icon;
|
||||
btn.addEventListener('click', () => {
|
||||
this.data.alignment = this.data.alignment === align.name ? '' : align.name;
|
||||
alignWrapper.querySelectorAll('button').forEach((b, idx) => {
|
||||
b.className = (this.data.alignment === alignments[idx].name) ? 'btn btn-xs btn-info mr-1 border' : 'btn btn-xs btn-light mr-1 border';
|
||||
});
|
||||
this.applyTuneToBlock();
|
||||
});
|
||||
alignWrapper.appendChild(btn);
|
||||
});
|
||||
wrapper.appendChild(alignWrapper);
|
||||
|
||||
// CSS Class Group
|
||||
const classGroup = document.createElement('div');
|
||||
classGroup.className = 'form-group mb-1';
|
||||
const classLabel = document.createElement('label');
|
||||
classLabel.className = 'small font-weight-bold text-secondary mb-0 d-block';
|
||||
classLabel.innerText = 'CSS Class';
|
||||
const classInput = document.createElement('input');
|
||||
classInput.type = 'text';
|
||||
classInput.className = 'form-control form-control-sm py-0';
|
||||
classInput.style.height = '24px';
|
||||
classInput.placeholder = 'e.g. lead text-primary font-weight-bold';
|
||||
classInput.value = this.data.cssClass || '';
|
||||
classInput.addEventListener('input', (e) => {
|
||||
this.data.cssClass = e.target.value.trim();
|
||||
this.applyTuneToBlock();
|
||||
});
|
||||
classGroup.appendChild(classLabel);
|
||||
classGroup.appendChild(classInput);
|
||||
wrapper.appendChild(classGroup);
|
||||
|
||||
// Custom CSS Style Group
|
||||
const styleGroup = document.createElement('div');
|
||||
styleGroup.className = 'form-group mb-1';
|
||||
const styleLabel = document.createElement('label');
|
||||
styleLabel.className = 'small font-weight-bold text-secondary mb-0 d-block';
|
||||
styleLabel.innerText = 'CSS Style';
|
||||
const styleInput = document.createElement('input');
|
||||
styleInput.type = 'text';
|
||||
styleInput.className = 'form-control form-control-sm py-0';
|
||||
styleInput.style.height = '24px';
|
||||
styleInput.placeholder = 'e.g. color: #dc3545; font-size: 1.5rem;';
|
||||
styleInput.value = this.data.customStyle || '';
|
||||
styleInput.addEventListener('input', (e) => {
|
||||
this.data.customStyle = e.target.value.trim();
|
||||
this.applyTuneToBlock();
|
||||
});
|
||||
styleGroup.appendChild(styleLabel);
|
||||
styleGroup.appendChild(styleInput);
|
||||
wrapper.appendChild(styleGroup);
|
||||
|
||||
// HTML ID Group
|
||||
const idGroup = document.createElement('div');
|
||||
idGroup.className = 'form-group mb-1';
|
||||
const idLabel = document.createElement('label');
|
||||
idLabel.className = 'small font-weight-bold text-secondary mb-0 d-block';
|
||||
idLabel.innerText = 'Element ID';
|
||||
const idInput = document.createElement('input');
|
||||
idInput.type = 'text';
|
||||
idInput.className = 'form-control form-control-sm py-0';
|
||||
idInput.style.height = '24px';
|
||||
idInput.placeholder = 'e.g. section-main-title';
|
||||
idInput.value = this.data.elementId || '';
|
||||
idInput.addEventListener('input', (e) => {
|
||||
this.data.elementId = e.target.value.trim();
|
||||
this.applyTuneToBlock();
|
||||
});
|
||||
idGroup.appendChild(idLabel);
|
||||
idGroup.appendChild(idInput);
|
||||
wrapper.appendChild(idGroup);
|
||||
|
||||
// Custom HTML Attributes Group
|
||||
const attrsGroup = document.createElement('div');
|
||||
attrsGroup.className = 'form-group mb-0';
|
||||
const attrsLabel = document.createElement('label');
|
||||
attrsLabel.className = 'small font-weight-bold text-secondary mb-0 d-block';
|
||||
attrsLabel.innerText = 'Custom Attributes';
|
||||
const attrsInput = document.createElement('input');
|
||||
attrsInput.type = 'text';
|
||||
attrsInput.className = 'form-control form-control-sm py-0';
|
||||
attrsInput.style.height = '24px';
|
||||
attrsInput.placeholder = 'e.g. data-aos="fade-up" target="_blank"';
|
||||
attrsInput.value = this.data.customAttrs || '';
|
||||
attrsInput.addEventListener('input', (e) => {
|
||||
this.data.customAttrs = e.target.value.trim();
|
||||
});
|
||||
attrsGroup.appendChild(attrsLabel);
|
||||
attrsGroup.appendChild(attrsInput);
|
||||
wrapper.appendChild(attrsGroup);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
applyTuneToBlock() {
|
||||
if (!this.block || !this.block.holder) return;
|
||||
const blockContent = this.block.holder.querySelector('.ce-block__content');
|
||||
if (blockContent) {
|
||||
// Live alignment preview
|
||||
blockContent.style.textAlign = this.data.alignment || '';
|
||||
// Live styling preview
|
||||
const currentElement = blockContent.firstElementChild;
|
||||
if (currentElement) {
|
||||
// Apply custom inline styles on top of defaults
|
||||
currentElement.style.cssText = this.data.customStyle || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
wrap(blockContent) {
|
||||
// Applies styling during initial render / loading
|
||||
if (this.data.alignment) {
|
||||
blockContent.style.textAlign = this.data.alignment;
|
||||
}
|
||||
const currentElement = blockContent.firstElementChild;
|
||||
if (currentElement && this.data.customStyle) {
|
||||
currentElement.style.cssText = this.data.customStyle;
|
||||
}
|
||||
return blockContent;
|
||||
}
|
||||
}
|
||||
|
||||
// Register globally
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['textStyling'] = { class: TextStylingTune };
|
||||
@@ -122,10 +122,47 @@ class SISTimelineTool {
|
||||
fileInput.style.display = 'none';
|
||||
|
||||
chooseBtn.addEventListener('click', () => {
|
||||
openMediaPickerModal((url) => {
|
||||
imageInput.value = url;
|
||||
updatePreview();
|
||||
});
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((url, mediaObj, config) => {
|
||||
imageInput.value = url;
|
||||
// Store config in hidden inputs on the item box
|
||||
let styleInp = itemBox.querySelector('.ce-timeline-input-imgstyle');
|
||||
if (!styleInp) {
|
||||
styleInp = document.createElement('input'); styleInp.type = 'hidden';
|
||||
styleInp.className = 'ce-timeline-input-imgstyle'; itemBox.appendChild(styleInp);
|
||||
}
|
||||
let classInp = itemBox.querySelector('.ce-timeline-input-imgclass');
|
||||
if (!classInp) {
|
||||
classInp = document.createElement('input'); classInp.type = 'hidden';
|
||||
classInp.className = 'ce-timeline-input-imgclass'; itemBox.appendChild(classInp);
|
||||
}
|
||||
let altInp = itemBox.querySelector('.ce-timeline-input-imgalt');
|
||||
if (!altInp) {
|
||||
altInp = document.createElement('input'); altInp.type = 'hidden';
|
||||
altInp.className = 'ce-timeline-input-imgalt'; itemBox.appendChild(altInp);
|
||||
}
|
||||
let attrsInp = itemBox.querySelector('.ce-timeline-input-imgattrs');
|
||||
if (!attrsInp) {
|
||||
attrsInp = document.createElement('input'); attrsInp.type = 'hidden';
|
||||
attrsInp.className = 'ce-timeline-input-imgattrs'; itemBox.appendChild(attrsInp);
|
||||
}
|
||||
let arInp = itemBox.querySelector('.ce-timeline-input-imgar');
|
||||
if (!arInp) {
|
||||
arInp = document.createElement('input'); arInp.type = 'hidden';
|
||||
arInp.className = 'ce-timeline-input-imgar'; itemBox.appendChild(arInp);
|
||||
}
|
||||
if (config) {
|
||||
styleInp.value = config.style || '';
|
||||
classInp.value = config.cssClass || '';
|
||||
altInp.value = config.alt || '';
|
||||
attrsInp.value = config.customAttributes || '';
|
||||
arInp.value = config.aspectRatio || '';
|
||||
}
|
||||
updatePreview();
|
||||
});
|
||||
} else {
|
||||
alert('SISMediaPicker is not loaded.');
|
||||
}
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', () => {
|
||||
@@ -263,8 +300,17 @@ class SISTimelineTool {
|
||||
const img = box.querySelector('.ce-timeline-input-image').value.trim();
|
||||
const attrEl = box.querySelector('.ce-timeline-input-attr');
|
||||
const attr = attrEl ? attrEl.value.trim() : '';
|
||||
const imgStyle = (box.querySelector('.ce-timeline-input-imgstyle') || {}).value || '';
|
||||
const imgClass = (box.querySelector('.ce-timeline-input-imgclass') || {}).value || '';
|
||||
const imgAlt = (box.querySelector('.ce-timeline-input-imgalt') || {}).value || '';
|
||||
const imgAttrs = (box.querySelector('.ce-timeline-input-imgattrs') || {}).value || '';
|
||||
const imgAspectRatio = (box.querySelector('.ce-timeline-input-imgar') || {}).value || '';
|
||||
if (date || desc || img || attr) {
|
||||
items.push({ date: date, description: desc, imageUrl: img, attributes: attr });
|
||||
items.push({
|
||||
date: date, description: desc, imageUrl: img, attributes: attr,
|
||||
imageStyle: imgStyle, imageClass: imgClass, imageAlt: imgAlt,
|
||||
imageAttrs: imgAttrs, imageAspectRatio: imgAspectRatio
|
||||
});
|
||||
}
|
||||
});
|
||||
const globalAttrEl = this.wrapper.querySelector('.ce-timeline-input-global-attr');
|
||||
@@ -281,111 +327,12 @@ window.SISEditorPlugins['timeline'] = {
|
||||
class: SISTimelineTool
|
||||
};
|
||||
|
||||
// Global Media Picker Modal logic
|
||||
// openMediaPickerModal — legacy stub, replaced by SISMediaPicker
|
||||
function openMediaPickerModal(onSelectCallback) {
|
||||
let modalEl = document.getElementById('globalMediaPickerModal');
|
||||
if (!modalEl) {
|
||||
// Create modal HTML
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="globalMediaPickerModal" tabindex="-1" role="dialog" aria-hidden="true" style="z-index: 10000;">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-photo-video"></i> Select Media from Library</h5>
|
||||
<button class="close" type="button" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="mediaPickerLoading" class="text-center py-4">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i>
|
||||
<p class="mt-2">Loading media...</p>
|
||||
</div>
|
||||
<div id="mediaPickerGrid" class="row" style="display:none; max-height: 400px; overflow-y: auto;">
|
||||
<!-- media items -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
modalEl = document.getElementById('globalMediaPickerModal');
|
||||
|
||||
// Add styles
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `
|
||||
.media-picker-item { cursor: pointer; border: 2px solid transparent; border-radius: 4px; overflow: hidden; margin-bottom: 15px; position: relative; }
|
||||
.media-picker-item:hover { border-color: #4e73df; }
|
||||
.media-picker-item img { width: 100%; height: 120px; object-fit: cover; }
|
||||
.media-picker-item .media-name { font-size: 11px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; padding: 4px; text-align: center; background: #f8f9fc; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Store callback
|
||||
window._mediaPickerCallback = onSelectCallback;
|
||||
|
||||
// Show modal using jQuery/Bootstrap
|
||||
if (window.jQuery) {
|
||||
$(modalEl).modal('show');
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open(function(url) { onSelectCallback(url); });
|
||||
} else {
|
||||
alert("Bootstrap/jQuery not loaded!");
|
||||
return;
|
||||
var url = prompt('Enter image URL:');
|
||||
if (url) onSelectCallback(url);
|
||||
}
|
||||
|
||||
// Fetch media
|
||||
const loading = document.getElementById('mediaPickerLoading');
|
||||
const grid = document.getElementById('mediaPickerGrid');
|
||||
loading.style.display = 'block';
|
||||
grid.style.display = 'none';
|
||||
|
||||
fetch('/api/manage/media/list')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
loading.style.display = 'none';
|
||||
grid.style.display = 'flex';
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (data.length === 0) {
|
||||
grid.innerHTML = '<div class="col-12 text-center text-muted py-4">No media found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
data.forEach(item => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-md-3 col-sm-4 col-6';
|
||||
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'media-picker-item shadow-sm';
|
||||
|
||||
const img = document.createElement('img');
|
||||
// Use a default image if not an image type or missing URL
|
||||
img.src = item.fileUrl ? item.fileUrl : 'https://via.placeholder.com/150';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'media-name';
|
||||
name.title = item.originalFilename || 'Unknown';
|
||||
name.textContent = item.originalFilename || 'Unknown';
|
||||
|
||||
itemDiv.appendChild(img);
|
||||
itemDiv.appendChild(name);
|
||||
|
||||
itemDiv.addEventListener('click', () => {
|
||||
if (window._mediaPickerCallback) {
|
||||
window._mediaPickerCallback(item.fileUrl);
|
||||
}
|
||||
$(modalEl).modal('hide');
|
||||
});
|
||||
|
||||
col.appendChild(itemDiv);
|
||||
grid.appendChild(col);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error fetching media:', err);
|
||||
loading.style.display = 'none';
|
||||
grid.style.display = 'block';
|
||||
grid.innerHTML = '<div class="col-12 text-center text-danger py-4">Failed to load media list.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
class SISTinyMceTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'TinyMCE Editor',
|
||||
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"></path><path d="M8 8h8"></path><path d="M12 8v8"></path><path d="M10 16h4"></path></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
html: data.html || ''
|
||||
};
|
||||
this.editorId = 'tinymce-editor-' + Math.random().toString(36).substring(7);
|
||||
this.editorInstance = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'sis-tinymce-block-wrapper border rounded p-2 bg-white';
|
||||
wrapper.style.minHeight = '280px';
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.id = this.editorId;
|
||||
textarea.value = this.data.html;
|
||||
textarea.style.width = '100%';
|
||||
textarea.style.minHeight = '250px';
|
||||
wrapper.appendChild(textarea);
|
||||
this.textareaElement = textarea;
|
||||
|
||||
this._initTinyMCE();
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
_initTinyMCE() {
|
||||
const self = this;
|
||||
if (typeof tinymce !== 'undefined') {
|
||||
self._createEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
// Global loading queue to prevent duplicate script tags
|
||||
window.SISTinyMceLoading = window.SISTinyMceLoading || {
|
||||
loaded: false,
|
||||
callbacks: []
|
||||
};
|
||||
|
||||
if (window.SISTinyMceLoading.loaded) {
|
||||
self._createEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
window.SISTinyMceLoading.callbacks.push(() => {
|
||||
self._createEditor();
|
||||
});
|
||||
|
||||
if (window.SISTinyMceLoading.callbacks.length === 1) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js';
|
||||
script.referrerPolicy = 'origin';
|
||||
script.onload = () => {
|
||||
window.SISTinyMceLoading.loaded = true;
|
||||
while (window.SISTinyMceLoading.callbacks.length > 0) {
|
||||
const cb = window.SISTinyMceLoading.callbacks.shift();
|
||||
try { cb(); } catch (e) { console.error(e); }
|
||||
}
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}
|
||||
|
||||
_createEditor() {
|
||||
const self = this;
|
||||
const textarea = this.textareaElement || document.getElementById(this.editorId);
|
||||
if (!textarea) return;
|
||||
|
||||
// Wait until the element is actually attached to the document DOM
|
||||
const checkAndInit = () => {
|
||||
if (!document.body.contains(textarea)) {
|
||||
requestAnimationFrame(checkAndInit);
|
||||
return;
|
||||
}
|
||||
|
||||
tinymce.init({
|
||||
target: textarea,
|
||||
height: 250,
|
||||
menubar: false,
|
||||
readonly: !!self.readOnly,
|
||||
plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount',
|
||||
toolbar: 'undo redo | blocks | bold italic forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image | code removeformat',
|
||||
setup: function(editor) {
|
||||
self.editorInstance = editor;
|
||||
editor.on('change keyup undo redo', function() {
|
||||
self.data.html = editor.getContent();
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkAndInit();
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
if (this.editorInstance) {
|
||||
this.data.html = this.editorInstance.getContent();
|
||||
}
|
||||
return {
|
||||
html: this.data.html
|
||||
};
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.editorInstance) {
|
||||
try {
|
||||
tinymce.remove(this.editorInstance);
|
||||
} catch (e) {
|
||||
console.warn('[SIS TinyMCE Tool] Cleanup error:', e);
|
||||
}
|
||||
this.editorInstance = null;
|
||||
}
|
||||
if (this.textareaElement) {
|
||||
this.textareaElement.remove();
|
||||
this.textareaElement = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register globally
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['tinymce'] = {
|
||||
class: SISTinyMceTool
|
||||
};
|
||||
})();
|
||||
@@ -1,36 +1,71 @@
|
||||
/**
|
||||
* SIS Media Picker & Library Modal Utility
|
||||
* Standalone reusable module to select and upload media files anywhere in the application.
|
||||
*
|
||||
*
|
||||
* Features:
|
||||
* - Server-side paginated media list (default 24 per page)
|
||||
* - Infinite-scroll lazy loading inside the grid (loads next page when scrolled near bottom)
|
||||
* - Debounced keyword search (server-side, 350 ms delay)
|
||||
* - Image config panel (size, aspect ratio, object-fit, class, style, alt, custom attrs)
|
||||
*
|
||||
* Usage:
|
||||
* window.openSISMediaModal(function(selectedUrl, selectedMediaObj) {
|
||||
* console.log("Selected Image URL:", selectedUrl);
|
||||
* });
|
||||
*
|
||||
* Or via object API:
|
||||
* SISMediaPicker.open(function(selectedUrl, selectedMediaObj) { ... });
|
||||
* SISMediaPicker.open(function(url, mediaObj, config) { ... });
|
||||
* window.openSISMediaModal(callback); // legacy compat
|
||||
*/
|
||||
(function (window, document) {
|
||||
'use strict';
|
||||
|
||||
var SISMediaPicker = {
|
||||
callback: null,
|
||||
mediaData: [],
|
||||
modalId: 'sisMediaLibraryModal',
|
||||
var PAGE_SIZE = 24;
|
||||
|
||||
var PREDEFINED_SIZES = [
|
||||
{ label: 'Auto', width: '', height: '' },
|
||||
{ label: '25%', width: '25%', height: '' },
|
||||
{ label: '50%', width: '50%', height: '' },
|
||||
{ label: '75%', width: '75%', height: '' },
|
||||
{ label: '100%', width: '100%', height: '' },
|
||||
{ label: '100px', width: '100px', height: '100px' },
|
||||
{ label: '150px', width: '150px', height: '150px' },
|
||||
{ label: '200px', width: '200px', height: '200px' },
|
||||
{ label: '300px', width: '300px', height: 'auto' },
|
||||
{ label: '400px', width: '400px', height: 'auto' },
|
||||
{ label: '500px', width: '500px', height: 'auto' },
|
||||
{ label: 'Full Width', width: '100%', height: 'auto' },
|
||||
{ label: 'Thumb (80×80)', width: '80px', height: '80px' },
|
||||
{ label: 'Avatar (120×120)',width: '120px', height: '120px' },
|
||||
{ label: 'Banner (1200×400)',width:'1200px', height: '400px' },
|
||||
];
|
||||
|
||||
var SISMediaPicker = {
|
||||
/* ── state ─────────────────────────────────────────── */
|
||||
callback: null,
|
||||
modalId: 'sisMediaLibraryModal',
|
||||
configPanelId: 'sisMediaConfigPanel',
|
||||
selectedUrl: null,
|
||||
selectedMediaObj:null,
|
||||
|
||||
// Pagination / lazy-load state
|
||||
currentPage: 0,
|
||||
currentKeyword: '',
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
searchTimer: null,
|
||||
|
||||
/* ── initModal ─────────────────────────────────────── */
|
||||
initModal: function () {
|
||||
var modalEl = document.getElementById(this.modalId);
|
||||
if (modalEl) return modalEl;
|
||||
|
||||
modalEl = document.createElement('div');
|
||||
modalEl.id = this.modalId;
|
||||
modalEl.id = this.modalId;
|
||||
modalEl.className = 'modal fade';
|
||||
modalEl.setAttribute('tabindex', '-1');
|
||||
modalEl.setAttribute('role', 'dialog');
|
||||
modalEl.setAttribute('aria-hidden', 'true');
|
||||
modalEl.innerHTML =
|
||||
modalEl.innerHTML =
|
||||
'<div class="modal-dialog modal-xl" role="document">' +
|
||||
'<div class="modal-content border-0 shadow-lg">' +
|
||||
|
||||
/* ── Header ─── */
|
||||
'<div class="modal-header bg-primary text-white p-3">' +
|
||||
'<h5 class="modal-title font-weight-bold" id="' + this.modalId + 'Label">' +
|
||||
'<i class="fas fa-images mr-2"></i> Thư viện Media / Chọn Hình ảnh' +
|
||||
@@ -39,81 +74,212 @@
|
||||
'<span aria-hidden="true">×</span>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="modal-body bg-light p-3">' +
|
||||
|
||||
/* ── Body ─── */
|
||||
'<div class="modal-body bg-light p-3" style="max-height: 85vh; overflow-y: auto;">' +
|
||||
|
||||
/* Toolbar */
|
||||
'<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 bg-white p-2 border rounded shadow-sm">' +
|
||||
'<div class="input-group input-group-sm mb-2 mb-md-0" style="max-width: 380px;">' +
|
||||
'<div class="input-group-prepend"><span class="input-group-text bg-light"><i class="fas fa-search text-muted"></i></span></div>' +
|
||||
'<input type="text" class="form-control" id="sisMediaSearchInput" placeholder="Tìm kiếm theo tên file..." />' +
|
||||
'<input type="text" class="form-control" id="sisMediaSearchInput" placeholder="Tìm kiếm theo tên file..." autocomplete="off" />' +
|
||||
'</div>' +
|
||||
'<div>' +
|
||||
'<label class="btn btn-sm btn-success mb-0 cursor-pointer shadow-sm" style="cursor: pointer;">' +
|
||||
'<div class="d-flex align-items-center">' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary mr-2" id="sisMediaRefreshBtn" title="Tải lại danh sách">' +
|
||||
'<i class="fas fa-sync-alt"></i>' +
|
||||
'</button>' +
|
||||
'<label class="btn btn-sm btn-success mb-0 shadow-sm" style="cursor: pointer;">' +
|
||||
'<i class="fas fa-cloud-upload-alt mr-1"></i> Tải ảnh mới lên' +
|
||||
'<input type="file" id="sisMediaUploadInput" accept="image/*" style="display: none;" />' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="sisMediaGrid" class="row" style="max-height: 480px; overflow-y: auto; align-content: start;">' +
|
||||
'<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải danh sách hình ảnh...</span></div>' +
|
||||
|
||||
/* Info bar */
|
||||
'<div id="sisMediaInfoBar" class="small text-muted mb-2 px-1" style="min-height: 18px;"></div>' +
|
||||
|
||||
/* Grid */
|
||||
'<div id="sisMediaGrid" class="row" style="max-height: 340px; overflow-y: auto; align-content: start;">' +
|
||||
'<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải...</span></div>' +
|
||||
'</div>' +
|
||||
|
||||
/* Lazy-load sentinel */
|
||||
'<div id="sisMediaSentinel" style="height: 1px;"></div>' +
|
||||
|
||||
/* Config Panel */
|
||||
'<div id="' + this.configPanelId + '" class="mt-3 p-3 bg-white border rounded shadow-sm" style="display: none;">' +
|
||||
'<h6 class="font-weight-bold text-primary mb-3"><i class="fas fa-sliders-h mr-1"></i> Cấu hình hình ảnh đã chọn</h6>' +
|
||||
'<div class="row">' +
|
||||
/* Preview */
|
||||
'<div class="col-md-3 text-center mb-3">' +
|
||||
'<p class="small text-muted mb-1 font-weight-bold">Xem trước</p>' +
|
||||
'<div class="border rounded p-2 bg-light" style="min-height: 100px; display: flex; align-items: center; justify-content: center;">' +
|
||||
'<img id="sisMediaConfigPreview" src="" alt="Preview" style="max-width: 100%; max-height: 150px; object-fit: contain;" />' +
|
||||
'</div>' +
|
||||
'<p id="sisMediaConfigName" class="small text-truncate mt-1 text-muted mb-0"></p>' +
|
||||
'</div>' +
|
||||
/* Config fields */
|
||||
'<div class="col-md-9">' +
|
||||
'<div class="row">' +
|
||||
/* Quick sizes */
|
||||
'<div class="col-12 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted d-block mb-1" style="font-size:10px;text-transform:uppercase;">Kích thước nhanh</label>' +
|
||||
'<div id="sisMediaSizeButtons" class="d-flex flex-wrap" style="gap:4px;"></div>' +
|
||||
'</div>' +
|
||||
/* Width */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Chiều rộng (width)</label>' +
|
||||
'<input type="text" id="sisMediaConfigWidth" class="form-control form-control-sm" placeholder="e.g. 100%, 300px, auto" />' +
|
||||
'</div>' +
|
||||
/* Height */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Chiều cao (height)</label>' +
|
||||
'<input type="text" id="sisMediaConfigHeight" class="form-control form-control-sm" placeholder="e.g. auto, 200px" />' +
|
||||
'</div>' +
|
||||
/* Object Fit */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Object Fit</label>' +
|
||||
'<select id="sisMediaConfigObjectFit" class="form-control form-control-sm">' +
|
||||
'<option value="">-- default --</option>' +
|
||||
'<option value="cover">cover</option>' +
|
||||
'<option value="contain">contain</option>' +
|
||||
'<option value="fill">fill</option>' +
|
||||
'<option value="none">none</option>' +
|
||||
'<option value="scale-down">scale-down</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
/* Aspect Ratio */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Aspect Ratio</label>' +
|
||||
'<select id="sisMediaConfigAspectRatio" class="form-control form-control-sm">' +
|
||||
'<option value="">-- none --</option>' +
|
||||
'<option value="1/1">1:1 (Square)</option>' +
|
||||
'<option value="4/3">4:3 (Standard)</option>' +
|
||||
'<option value="16/9">16:9 (Widescreen)</option>' +
|
||||
'<option value="21/9">21:9 (Ultrawide)</option>' +
|
||||
'<option value="3/4">3:4 (Portrait)</option>' +
|
||||
'<option value="9/16">9:16 (Vertical Video)</option>' +
|
||||
'<option value="2/1">2:1 (Panorama)</option>' +
|
||||
'<option value="3/2">3:2 (Photo)</option>' +
|
||||
'<option value="5/4">5:4</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
/* CSS Class */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">CSS Class</label>' +
|
||||
'<input type="text" id="sisMediaConfigClass" class="form-control form-control-sm" placeholder="e.g. img-fluid rounded shadow" />' +
|
||||
'</div>' +
|
||||
/* Inline Style */
|
||||
'<div class="col-md-6 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Inline Style (CSS)</label>' +
|
||||
'<input type="text" id="sisMediaConfigStyle" class="form-control form-control-sm" placeholder="e.g. border-radius: 8px; opacity: 0.9;" />' +
|
||||
'</div>' +
|
||||
/* Alt Text */
|
||||
'<div class="col-md-6 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Alt Text</label>' +
|
||||
'<input type="text" id="sisMediaConfigAlt" class="form-control form-control-sm" placeholder="Image description..." />' +
|
||||
'</div>' +
|
||||
/* Custom Attributes */
|
||||
'<div class="col-md-6 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Custom Attributes (HTML)</label>' +
|
||||
'<input type="text" id="sisMediaConfigAttrs" class="form-control form-control-sm" placeholder=\'e.g. data-id="5" loading="lazy"\' />' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="text-right mt-2">' +
|
||||
'<button type="button" class="btn btn-sm btn-secondary mr-2" id="sisMediaConfigCancel">Hủy</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-primary" id="sisMediaConfigConfirm"><i class="fas fa-check mr-1"></i> Chọn ảnh này</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
/* Footer */
|
||||
'<div class="modal-footer bg-white p-2">' +
|
||||
'<button type="button" class="btn btn-sm btn-secondary px-3" data-dismiss="modal">Đóng</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
|
||||
document.body.appendChild(modalEl);
|
||||
|
||||
// Bind Search Input Handler
|
||||
// Build quick-size buttons
|
||||
var sizeContainer = document.getElementById('sisMediaSizeButtons');
|
||||
if (sizeContainer) {
|
||||
PREDEFINED_SIZES.forEach(function (size) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-xs btn-outline-secondary mb-1';
|
||||
btn.style.cssText = 'font-size:10px;padding:1px 6px;margin-right:3px;';
|
||||
btn.innerText = size.label;
|
||||
btn.addEventListener('click', function () {
|
||||
var wEl = document.getElementById('sisMediaConfigWidth');
|
||||
var hEl = document.getElementById('sisMediaConfigHeight');
|
||||
if (wEl) wEl.value = size.width;
|
||||
if (hEl) hEl.value = size.height;
|
||||
sizeContainer.querySelectorAll('button').forEach(function (b) {
|
||||
b.classList.replace('btn-secondary', 'btn-outline-secondary');
|
||||
});
|
||||
btn.classList.replace('btn-outline-secondary', 'btn-secondary');
|
||||
});
|
||||
sizeContainer.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Event delegation (attached once on modal, never on document) ── */
|
||||
var self = this;
|
||||
document.addEventListener('input', function (e) {
|
||||
|
||||
// Debounced search
|
||||
modalEl.addEventListener('input', function (e) {
|
||||
if (e.target && e.target.id === 'sisMediaSearchInput') {
|
||||
var term = e.target.value.toLowerCase().trim();
|
||||
var filtered = self.mediaData.filter(function (item) {
|
||||
var name = (item.originalFilename || item.name || '').toLowerCase();
|
||||
return name.includes(term);
|
||||
});
|
||||
self.renderGrid(filtered);
|
||||
}
|
||||
});
|
||||
|
||||
// Bind Upload Input Handler
|
||||
document.addEventListener('change', function (e) {
|
||||
if (e.target && e.target.id === 'sisMediaUploadInput') {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
var grid = document.getElementById('sisMediaGrid');
|
||||
if (grid) {
|
||||
grid.innerHTML = '<div class="col-12 text-center text-info py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải tệp lên server...</span></div>';
|
||||
}
|
||||
|
||||
fetch('/api/manage/media/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
if (data && data.success === 1 && data.file && data.file.url) {
|
||||
self.selectItem(data.file.url, data.file);
|
||||
} else {
|
||||
alert('Tải tệp lên thất bại. Vui lòng thử lại!');
|
||||
self.fetchMediaList();
|
||||
clearTimeout(self.searchTimer);
|
||||
self.searchTimer = setTimeout(function () {
|
||||
var kw = e.target.value.trim();
|
||||
if (kw !== self.currentKeyword) {
|
||||
self.currentKeyword = kw;
|
||||
self._resetAndFetch();
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
alert('Lỗi tải tệp: ' + err.message);
|
||||
self.fetchMediaList();
|
||||
});
|
||||
}, 350);
|
||||
}
|
||||
});
|
||||
|
||||
// Upload
|
||||
modalEl.addEventListener('change', function (e) {
|
||||
if (e.target && e.target.id === 'sisMediaUploadInput') {
|
||||
var file = e.target.files && e.target.files[0];
|
||||
if (!file) return;
|
||||
self._doUpload(file);
|
||||
// reset so same file can be uploaded again
|
||||
e.target.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Button clicks
|
||||
modalEl.addEventListener('click', function (e) {
|
||||
var id = e.target && e.target.id;
|
||||
var refreshBtn = e.target && e.target.closest && e.target.closest('#sisMediaRefreshBtn');
|
||||
if (refreshBtn || id === 'sisMediaRefreshBtn') {
|
||||
self._resetAndFetch(); return;
|
||||
}
|
||||
if (id === 'sisMediaConfigCancel') { self.hideConfigPanel(); return; }
|
||||
if (id === 'sisMediaConfigConfirm') { self.confirmWithConfig(); return; }
|
||||
});
|
||||
|
||||
/* ── Infinite scroll on the grid ── */
|
||||
var grid = document.getElementById('sisMediaGrid');
|
||||
if (grid) {
|
||||
grid.addEventListener('scroll', function () {
|
||||
// trigger when within 80 px of bottom
|
||||
if (grid.scrollHeight - grid.scrollTop - grid.clientHeight < 80) {
|
||||
self._maybeLoadMore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return modalEl;
|
||||
},
|
||||
|
||||
/* ── open ────────────────────────────────────────────── */
|
||||
open: function (callback) {
|
||||
this.callback = callback;
|
||||
var modalEl = this.initModal();
|
||||
@@ -124,66 +290,301 @@
|
||||
modalEl.style.display = 'block';
|
||||
modalEl.classList.add('show');
|
||||
}
|
||||
this.fetchMediaList();
|
||||
|
||||
// Reset search input
|
||||
var searchEl = document.getElementById('sisMediaSearchInput');
|
||||
if (searchEl) searchEl.value = '';
|
||||
this.currentKeyword = '';
|
||||
|
||||
this._resetAndFetch();
|
||||
},
|
||||
|
||||
fetchMediaList: function () {
|
||||
/* ── internal helpers ───────────────────────────────── */
|
||||
|
||||
/** Reset pagination and fetch page 0 fresh */
|
||||
_resetAndFetch: function () {
|
||||
this.currentPage = 0;
|
||||
this.totalPages = 1;
|
||||
this.loading = false;
|
||||
this.hideConfigPanel();
|
||||
var grid = document.getElementById('sisMediaGrid');
|
||||
if (grid) {
|
||||
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x text-primary"></i><br/><span class="mt-2 d-block small">Đang nạp thư viện...</span></div>';
|
||||
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x text-primary"></i><br/><span class="mt-2 d-block small">Đang tải...</span></div>';
|
||||
grid.scrollTop = 0;
|
||||
}
|
||||
this._updateInfoBar('');
|
||||
this._fetchPage(0, true);
|
||||
},
|
||||
|
||||
/** Load the next page if available and not already loading */
|
||||
_maybeLoadMore: function () {
|
||||
if (this.loading) return;
|
||||
if (this.currentPage + 1 >= this.totalPages) return;
|
||||
this._fetchPage(this.currentPage + 1, false);
|
||||
},
|
||||
|
||||
/** Fetch a single page from the server
|
||||
* @param {number} page - 0-based page index
|
||||
* @param {boolean} reset - true → replace grid contents; false → append
|
||||
*/
|
||||
_fetchPage: function (page, reset) {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
|
||||
var self = this;
|
||||
fetch('/api/manage/media/list')
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
self.mediaData = data || [];
|
||||
self.renderGrid(self.mediaData);
|
||||
var url = '/api/manage/media/list?page=' + page + '&size=' + PAGE_SIZE;
|
||||
if (this.currentKeyword) {
|
||||
url += '&keyword=' + encodeURIComponent(this.currentKeyword);
|
||||
}
|
||||
|
||||
// Append a loading row at the bottom when not resetting
|
||||
if (!reset) {
|
||||
var grid = document.getElementById('sisMediaGrid');
|
||||
if (grid) {
|
||||
var loaderRow = document.createElement('div');
|
||||
loaderRow.className = 'col-12 text-center py-2 text-muted';
|
||||
loaderRow.id = 'sisMediaPageLoader';
|
||||
loaderRow.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Đang tải thêm...';
|
||||
grid.appendChild(loaderRow);
|
||||
}
|
||||
}
|
||||
|
||||
fetch(url)
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.json();
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (grid) {
|
||||
grid.innerHTML = '<div class="col-12 text-center text-danger py-4"><i class="fas fa-exclamation-triangle fa-2x mb-2"></i><br/>Không thể tải danh sách tệp. Bạn có thể dán đường dẫn URL trực tiếp.</div>';
|
||||
.then(function (data) {
|
||||
self.loading = false;
|
||||
self.currentPage = data.number || page;
|
||||
self.totalPages = data.totalPages || 1;
|
||||
var items = data.content || [];
|
||||
var total = data.totalElements || 0;
|
||||
|
||||
// Remove loader row
|
||||
var existingLoader = document.getElementById('sisMediaPageLoader');
|
||||
if (existingLoader) existingLoader.remove();
|
||||
|
||||
if (reset) {
|
||||
self._renderGrid(items, true, total);
|
||||
} else {
|
||||
self._appendItems(items);
|
||||
}
|
||||
|
||||
// Update info bar
|
||||
var loaded = Math.min((self.currentPage + 1) * PAGE_SIZE, total);
|
||||
var kw = self.currentKeyword;
|
||||
var info = kw
|
||||
? 'Tìm "' + kw + '": ' + total + ' ảnh — đang hiển thị ' + loaded
|
||||
: total + ' ảnh — đang hiển thị ' + loaded;
|
||||
if (self.currentPage + 1 >= self.totalPages) {
|
||||
info += ' (đã tải tất cả)';
|
||||
}
|
||||
self._updateInfoBar(info);
|
||||
})
|
||||
.catch(function () {
|
||||
self.loading = false;
|
||||
var existingLoader = document.getElementById('sisMediaPageLoader');
|
||||
if (existingLoader) existingLoader.remove();
|
||||
if (reset) {
|
||||
var grid = document.getElementById('sisMediaGrid');
|
||||
if (grid) {
|
||||
grid.innerHTML = '<div class="col-12 text-center text-danger py-4"><i class="fas fa-exclamation-triangle fa-2x mb-2"></i><br/>Không thể tải danh sách. Vui lòng thử lại.</div>';
|
||||
}
|
||||
}
|
||||
self._updateInfoBar('');
|
||||
});
|
||||
},
|
||||
|
||||
renderGrid: function (list) {
|
||||
_updateInfoBar: function (text) {
|
||||
var bar = document.getElementById('sisMediaInfoBar');
|
||||
if (bar) bar.textContent = text;
|
||||
},
|
||||
|
||||
/** Replace entire grid with a fresh list of items */
|
||||
_renderGrid: function (list, clear, total) {
|
||||
var grid = document.getElementById('sisMediaGrid');
|
||||
if (!grid) return;
|
||||
|
||||
if (clear) grid.innerHTML = '';
|
||||
|
||||
if (!list || list.length === 0) {
|
||||
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="far fa-folder-open fa-2x mb-2"></i><br/>Chưa có tệp hình ảnh nào. Bấm nút "Tải ảnh mới lên" để thêm.</div>';
|
||||
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="far fa-folder-open fa-2x mb-2"></i><br/>Không tìm thấy hình ảnh nào.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
grid.innerHTML = list.map(function (item) {
|
||||
var url = item.fileUrl || item.url || '';
|
||||
var name = item.originalFilename || item.name || 'Image';
|
||||
return '<div class="col-lg-2 col-md-3 col-sm-4 col-6 mb-3">' +
|
||||
'<div class="card h-100 border shadow-sm media-select-card" style="cursor: pointer; transition: all 0.2s;" data-url="' + url + '">' +
|
||||
'<div style="height: 110px; background: #f8f9fc; display: flex; align-items: center; justify-content: center; overflow: hidden;" class="p-1">' +
|
||||
'<img src="' + url + '" style="max-height: 100%; max-width: 100%; object-fit: contain;" alt="' + name + '" />' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-2 text-center bg-white border-top">' +
|
||||
'<p class="card-text small text-truncate m-0 font-weight-bold text-dark" title="' + name + '">' + name + '</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
this._appendItems(list);
|
||||
},
|
||||
|
||||
/** Append card elements for each item in the list */
|
||||
_appendItems: function (list) {
|
||||
var grid = document.getElementById('sisMediaGrid');
|
||||
if (!grid || !list || list.length === 0) return;
|
||||
|
||||
var self = this;
|
||||
list.forEach(function (item) {
|
||||
var url = item.fileUrl || item.url || '';
|
||||
var name = item.originalFilename || item.name || 'Image';
|
||||
|
||||
var col = document.createElement('div');
|
||||
col.className = 'col-lg-2 col-md-3 col-sm-4 col-6 mb-3';
|
||||
|
||||
var card = document.createElement('div');
|
||||
card.className = 'card h-100 border shadow-sm media-select-card';
|
||||
card.style.cssText = 'cursor:pointer;transition:all 0.2s;';
|
||||
card.setAttribute('data-url', url);
|
||||
card.setAttribute('data-name', name);
|
||||
|
||||
card.innerHTML =
|
||||
'<div style="height:110px;background:#f8f9fc;display:flex;align-items:center;justify-content:center;overflow:hidden;" class="p-1">' +
|
||||
'<img src="' + url + '" loading="lazy" style="max-height:100%;max-width:100%;object-fit:contain;" alt="' + self._esc(name) + '" />' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-2 text-center bg-white border-top">' +
|
||||
'<p class="card-text small text-truncate m-0 font-weight-bold text-dark" title="' + self._esc(name) + '">' + self._esc(name) + '</p>' +
|
||||
'</div>';
|
||||
|
||||
// Add click listeners to items
|
||||
grid.querySelectorAll('.media-select-card').forEach(function (card) {
|
||||
card.addEventListener('click', function () {
|
||||
var selectedUrl = card.getAttribute('data-url');
|
||||
self.selectItem(selectedUrl);
|
||||
grid.querySelectorAll('.media-select-card').forEach(function (c) {
|
||||
c.style.outline = '';
|
||||
});
|
||||
card.style.outline = '3px solid #007bff';
|
||||
self.showConfigPanel(url, { name: name, url: url });
|
||||
});
|
||||
|
||||
col.appendChild(card);
|
||||
grid.appendChild(col);
|
||||
});
|
||||
},
|
||||
|
||||
/** Minimal HTML-escape helper */
|
||||
_esc: function (str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
},
|
||||
|
||||
_doUpload: function (file) {
|
||||
var self = this;
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
var grid = document.getElementById('sisMediaGrid');
|
||||
if (grid) {
|
||||
grid.innerHTML = '<div class="col-12 text-center text-info py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải tệp lên server...</span></div>';
|
||||
}
|
||||
|
||||
fetch('/api/manage/media/upload', { method: 'POST', body: formData })
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
if (data && data.success === 1 && data.file && data.file.url) {
|
||||
// Re-fetch from page 0 so new item appears, then show config
|
||||
self._resetAndFetch();
|
||||
// Show config after a short delay to let grid load
|
||||
setTimeout(function () {
|
||||
self.showConfigPanel(data.file.url, data.file);
|
||||
}, 500);
|
||||
} else {
|
||||
alert('Tải tệp lên thất bại. Vui lòng thử lại!');
|
||||
self._resetAndFetch();
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
alert('Lỗi tải tệp: ' + err.message);
|
||||
self._resetAndFetch();
|
||||
});
|
||||
},
|
||||
|
||||
/* ── Config Panel ────────────────────────────────────── */
|
||||
showConfigPanel: function (url, mediaObj) {
|
||||
this.selectedUrl = url;
|
||||
this.selectedMediaObj = mediaObj || {};
|
||||
|
||||
var panel = document.getElementById(this.configPanelId);
|
||||
if (!panel) return;
|
||||
|
||||
var previewImg = document.getElementById('sisMediaConfigPreview');
|
||||
if (previewImg) previewImg.src = url;
|
||||
|
||||
var nameEl = document.getElementById('sisMediaConfigName');
|
||||
if (nameEl) nameEl.textContent = (mediaObj && (mediaObj.originalFilename || mediaObj.name)) || '';
|
||||
|
||||
// Reset all fields
|
||||
['sisMediaConfigWidth','sisMediaConfigHeight','sisMediaConfigStyle','sisMediaConfigClass','sisMediaConfigAlt','sisMediaConfigAttrs'].forEach(function (id) {
|
||||
var el = document.getElementById(id); if (el) el.value = '';
|
||||
});
|
||||
['sisMediaConfigObjectFit','sisMediaConfigAspectRatio'].forEach(function (id) {
|
||||
var el = document.getElementById(id); if (el) el.value = '';
|
||||
});
|
||||
|
||||
var sizeContainer = document.getElementById('sisMediaSizeButtons');
|
||||
if (sizeContainer) {
|
||||
sizeContainer.querySelectorAll('button').forEach(function (b) {
|
||||
b.classList.replace('btn-secondary', 'btn-outline-secondary');
|
||||
});
|
||||
}
|
||||
|
||||
panel.style.display = 'block';
|
||||
setTimeout(function () {
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
},
|
||||
|
||||
hideConfigPanel: function () {
|
||||
var panel = document.getElementById(this.configPanelId);
|
||||
if (panel) panel.style.display = 'none';
|
||||
this.selectedUrl = null;
|
||||
this.selectedMediaObj = null;
|
||||
},
|
||||
|
||||
confirmWithConfig: function () {
|
||||
if (!this.selectedUrl) return;
|
||||
|
||||
var g = function (id) { return (document.getElementById(id) || {}).value || ''; };
|
||||
var width = g('sisMediaConfigWidth');
|
||||
var height = g('sisMediaConfigHeight');
|
||||
var objectFit = g('sisMediaConfigObjectFit');
|
||||
var aspectRatio= g('sisMediaConfigAspectRatio');
|
||||
var style = g('sisMediaConfigStyle');
|
||||
var cssClass = g('sisMediaConfigClass');
|
||||
var alt = g('sisMediaConfigAlt');
|
||||
var customAttrs= g('sisMediaConfigAttrs');
|
||||
|
||||
var computedStyle = '';
|
||||
if (width) computedStyle += 'width: ' + width + '; ';
|
||||
if (height) computedStyle += 'height: ' + height + '; ';
|
||||
if (aspectRatio) computedStyle += 'aspect-ratio: ' + aspectRatio + '; ';
|
||||
if (objectFit) computedStyle += 'object-fit: ' + objectFit + '; ';
|
||||
if (style) computedStyle += style;
|
||||
computedStyle = computedStyle.trim();
|
||||
|
||||
var config = {
|
||||
style: computedStyle, cssClass: cssClass, alt: alt,
|
||||
customAttributes: customAttrs, width: width, height: height,
|
||||
objectFit: objectFit, aspectRatio: aspectRatio
|
||||
};
|
||||
|
||||
if (typeof this.callback === 'function') {
|
||||
this.callback(this.selectedUrl, this.selectedMediaObj, config);
|
||||
}
|
||||
|
||||
var modalEl = document.getElementById(this.modalId);
|
||||
if (modalEl) {
|
||||
if (typeof $ !== 'undefined' && $.fn && $.fn.modal) {
|
||||
$(modalEl).modal('hide');
|
||||
} else {
|
||||
modalEl.style.display = 'none';
|
||||
modalEl.classList.remove('show');
|
||||
}
|
||||
}
|
||||
this.hideConfigPanel();
|
||||
},
|
||||
|
||||
/** Legacy: immediate select without config panel */
|
||||
selectItem: function (url, mediaObj) {
|
||||
if (typeof this.callback === 'function') {
|
||||
this.callback(url, mediaObj);
|
||||
this.callback(url, mediaObj, {});
|
||||
}
|
||||
var modalEl = document.getElementById(this.modalId);
|
||||
if (modalEl) {
|
||||
@@ -197,12 +598,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Expose standalone module globally
|
||||
window.SISMediaPicker = SISMediaPicker;
|
||||
|
||||
// Backward-compatible global helper function
|
||||
window.openSISMediaModal = function (callback) {
|
||||
SISMediaPicker.open(callback);
|
||||
};
|
||||
window.openSISMediaModal = function (cb) { SISMediaPicker.open(cb); };
|
||||
|
||||
})(window, document);
|
||||
|
||||
@@ -75,9 +75,9 @@
|
||||
<option value="">-- None (Top Level) --</option>
|
||||
<th:block th:each="topOpt : ${menu.items}" th:if="${topOpt.parent == null}">
|
||||
<option th:value="${topOpt.id}" th:text="${topOpt.label}"></option>
|
||||
<th:block th:each="childOpt : ${menu.items}" th:if="${childOpt.parent != null and childOpt.parent.id == topOpt.id}">
|
||||
<th:block th:each="childOpt : ${topOpt.children}">
|
||||
<option th:value="${childOpt.id}" th:text="'-- ' + ${childOpt.label}"></option>
|
||||
<th:block th:each="grandChildOpt : ${menu.items}" th:if="${grandChildOpt.parent != null and grandChildOpt.parent.id == childOpt.id}">
|
||||
<th:block th:each="grandChildOpt : ${childOpt.children}">
|
||||
<option th:value="${grandChildOpt.id}" th:text="'---- ' + ${grandChildOpt.label}"></option>
|
||||
</th:block>
|
||||
</th:block>
|
||||
@@ -108,15 +108,22 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="badge badge-primary badge-pill mr-2" th:text="'Order: ' + ${topItem.displayOrder}">0</span>
|
||||
<button class="btn btn-sm btn-info" data-toggle="modal" th:data-target="'#editModal-' + ${topItem.id}">Edit</button>
|
||||
<form th:action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${topItem.id})}" method="post" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this menu item?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-info edit-item-btn"
|
||||
data-toggle="modal" data-target="#editItemModal"
|
||||
th:data-id="${topItem.id}"
|
||||
th:data-label="${topItem.label}"
|
||||
th:data-url="${topItem.url}"
|
||||
th:data-title="${topItem.title}"
|
||||
th:data-image-url="${topItem.imageUrl}"
|
||||
th:data-parent-id="${topItem.parent != null ? topItem.parent.id : ''}"
|
||||
th:data-display-order="${topItem.displayOrder}">Edit</button>
|
||||
<button type="button" class="btn btn-sm btn-danger delete-item-btn"
|
||||
th:data-action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${topItem.id})}">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Children Items -->
|
||||
<th:block th:each="child : ${menu.items}" th:if="${child.parent != null and child.parent.id == topItem.id}">
|
||||
<th:block th:each="child : ${topItem.children}">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center bg-light" style="margin-left: 2rem; border-left: 4px solid #4e73df;">
|
||||
<div>
|
||||
<strong th:text="${child.label}">Link Text</strong>
|
||||
@@ -126,14 +133,21 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="badge badge-primary badge-pill mr-2" th:text="'Order: ' + ${child.displayOrder}">0</span>
|
||||
<button class="btn btn-sm btn-info" data-toggle="modal" th:data-target="'#editModal-' + ${child.id}">Edit</button>
|
||||
<form th:action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${child.id})}" method="post" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this menu item?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-info edit-item-btn"
|
||||
data-toggle="modal" data-target="#editItemModal"
|
||||
th:data-id="${child.id}"
|
||||
th:data-label="${child.label}"
|
||||
th:data-url="${child.url}"
|
||||
th:data-title="${child.title}"
|
||||
th:data-image-url="${child.imageUrl}"
|
||||
th:data-parent-id="${child.parent != null ? child.parent.id : ''}"
|
||||
th:data-display-order="${child.displayOrder}">Edit</button>
|
||||
<button type="button" class="btn btn-sm btn-danger delete-item-btn"
|
||||
th:data-action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${child.id})}">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
<!-- Grandchildren Items -->
|
||||
<th:block th:each="grandchild : ${menu.items}" th:if="${grandchild.parent != null and grandchild.parent.id == child.id}">
|
||||
<th:block th:each="grandchild : ${child.children}">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center bg-light" style="margin-left: 4rem; border-left: 4px solid #36b9cc;">
|
||||
<div>
|
||||
<strong th:text="${grandchild.label}">Link Text</strong>
|
||||
@@ -143,10 +157,17 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="badge badge-primary badge-pill mr-2" th:text="'Order: ' + ${grandchild.displayOrder}">0</span>
|
||||
<button class="btn btn-sm btn-info" data-toggle="modal" th:data-target="'#editModal-' + ${grandchild.id}">Edit</button>
|
||||
<form th:action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${grandchild.id})}" method="post" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this menu item?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-info edit-item-btn"
|
||||
data-toggle="modal" data-target="#editItemModal"
|
||||
th:data-id="${grandchild.id}"
|
||||
th:data-label="${grandchild.label}"
|
||||
th:data-url="${grandchild.url}"
|
||||
th:data-title="${grandchild.title}"
|
||||
th:data-image-url="${grandchild.imageUrl}"
|
||||
th:data-parent-id="${grandchild.parent != null ? grandchild.parent.id : ''}"
|
||||
th:data-display-order="${grandchild.displayOrder}">Edit</button>
|
||||
<button type="button" class="btn btn-sm btn-danger delete-item-btn"
|
||||
th:data-action="@{/manage/menus/{menuId}/items/{itemId}/delete(menuId=${menu.id}, itemId=${grandchild.id})}">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
</th:block>
|
||||
@@ -160,67 +181,134 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modals -->
|
||||
<th:block th:each="item : ${menu.items}">
|
||||
<div class="modal fade" th:id="'editModal-' + ${item.id}" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<form th:action="@{/manage/menus/{menuId}/items/{itemId}/update(menuId=${menu.id}, itemId=${item.id})}" method="post">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Menu Item</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Link Text</label>
|
||||
<input type="text" class="form-control" name="label" th:value="${item.label}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>URL</label>
|
||||
<input type="text" class="form-control" name="url" th:value="${item.url}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Title (Tùy chọn)</label>
|
||||
<input type="text" class="form-control" name="title" th:value="${item.title}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Image URL (Tùy chọn)</label>
|
||||
<input type="text" class="form-control" name="imageUrl" th:value="${item.imageUrl}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Parent Item (Optional)</label>
|
||||
<select class="form-control" name="parentId">
|
||||
<option value="">-- None (Top Level) --</option>
|
||||
<th:block th:each="topOpt : ${menu.items}" th:if="${topOpt.parent == null and topOpt.id != item.id}">
|
||||
<option th:value="${topOpt.id}" th:text="${topOpt.label}" th:selected="${item.parent != null and item.parent.id == topOpt.id}"></option>
|
||||
<th:block th:each="childOpt : ${menu.items}" th:if="${childOpt.parent != null and childOpt.parent.id == topOpt.id and childOpt.id != item.id}">
|
||||
<option th:value="${childOpt.id}" th:text="'-- ' + ${childOpt.label}" th:selected="${item.parent != null and item.parent.id == childOpt.id}"></option>
|
||||
<th:block th:each="grandChildOpt : ${menu.items}" th:if="${grandChildOpt.parent != null and grandChildOpt.parent.id == childOpt.id and grandChildOpt.id != item.id}">
|
||||
<option th:value="${grandChildOpt.id}" th:text="'---- ' + ${grandChildOpt.label}" th:selected="${item.parent != null and item.parent.id == grandChildOpt.id}"></option>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Display Order</label>
|
||||
<input type="number" class="form-control" name="displayOrder" th:value="${item.displayOrder}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Single Dynamic Edit Modal -->
|
||||
<div class="modal fade" id="editItemModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<form id="editItemForm" method="post" th:action="@{/manage/menus/{menuId}/items/0/update(menuId=${menu.id})}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Menu Item</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Link Text</label>
|
||||
<input type="text" class="form-control" id="modalLabel" name="label" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>URL</label>
|
||||
<input type="text" class="form-control" id="modalUrl" name="url" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Title (Tùy chọn)</label>
|
||||
<input type="text" class="form-control" id="modalTitle" name="title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Image URL (Tùy chọn)</label>
|
||||
<input type="text" class="form-control" id="modalImageUrl" name="imageUrl">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Parent Item (Optional)</label>
|
||||
<select class="form-control" id="modalParentId" name="parentId">
|
||||
<option value="">-- None (Top Level) --</option>
|
||||
<th:block th:each="topOpt : ${menu.items}" th:if="${topOpt.parent == null}">
|
||||
<option th:value="${topOpt.id}" th:text="${topOpt.label}"></option>
|
||||
<th:block th:each="childOpt : ${topOpt.children}">
|
||||
<option th:value="${childOpt.id}" th:text="'-- ' + ${childOpt.label}"></option>
|
||||
<th:block th:each="grandChildOpt : ${childOpt.children}">
|
||||
<option th:value="${grandChildOpt.id}" th:text="'---- ' + ${grandChildOpt.label}"></option>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Display Order</label>
|
||||
<input type="number" class="form-control" id="modalDisplayOrder" name="displayOrder">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteItemModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteModalLabel">Xác nhận xóa</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Bạn có chắc chắn muốn xóa menu item này không?
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Hủy</button>
|
||||
<form id="deleteItemForm" method="post" class="d-inline" th:action="@{/manage/menus/{menuId}/items/0/delete(menuId=${menu.id})}">
|
||||
<button type="submit" class="btn btn-danger">Xóa</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
var menuId = /*[[${menu.id}]]*/ '';
|
||||
|
||||
$(document).on('click', '.edit-item-btn', function (e) {
|
||||
e.preventDefault();
|
||||
var $btn = $(this);
|
||||
var id = $btn.data('id');
|
||||
var label = $btn.data('label') || '';
|
||||
var url = $btn.data('url') || '';
|
||||
var title = $btn.data('title') || '';
|
||||
var imageUrl = $btn.data('image-url') || '';
|
||||
var parentId = $btn.data('parent-id') || '';
|
||||
var displayOrder = $btn.data('display-order') || '0';
|
||||
|
||||
$('#editItemForm').attr('action', '/manage/menus/' + menuId + '/items/' + id + '/update');
|
||||
$('#modalLabel').val(label);
|
||||
$('#modalUrl').val(url);
|
||||
$('#modalTitle').val(title);
|
||||
$('#modalImageUrl').val(imageUrl);
|
||||
$('#modalParentId').val(parentId);
|
||||
$('#modalDisplayOrder').val(displayOrder);
|
||||
|
||||
$('#modalParentId option').each(function () {
|
||||
if ($(this).val() == id) {
|
||||
$(this).prop('disabled', true);
|
||||
} else {
|
||||
$(this).prop('disabled', false);
|
||||
}
|
||||
});
|
||||
|
||||
$('#editItemModal').modal('show');
|
||||
});
|
||||
|
||||
// jQuery Delete Confirmation Handler
|
||||
$(document).on('click', '.delete-item-btn', function (e) {
|
||||
e.preventDefault();
|
||||
var actionUrl = $(this).data('action');
|
||||
$('#deleteItemForm').attr('action', actionUrl);
|
||||
$('#deleteItemModal').modal('show');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -442,6 +442,9 @@
|
||||
<script th:src="@{/js/manage/editor-plugins/hover-card.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/tag.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/nav.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/hero-banner.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/text-styling.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/tiny-mce.js}"></script>
|
||||
|
||||
<!-- SIS Standalone Media Picker Library -->
|
||||
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
|
||||
|
||||
@@ -38,33 +38,35 @@
|
||||
>
|
||||
<!-- 1. HTML Snippet Block (Custom) -->
|
||||
<th:block th:if="${block['type'] == 'snippet'}">
|
||||
<div th:utext="${block.data['htmlContent']}" th:remove="tag"></div>
|
||||
<div th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
|
||||
<div th:utext="${block.data['htmlContent']}" th:remove="tag"></div>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- 2. Header Block -->
|
||||
<th:block th:if="${block['type'] == 'header'}">
|
||||
<th:block th:switch="${block.data['level']}">
|
||||
<h1 th:case="1" th:utext="${block.data['text']}"></h1>
|
||||
<h2 th:case="2" th:utext="${block.data['text']}"></h2>
|
||||
<h3 th:case="3" th:utext="${block.data['text']}"></h3>
|
||||
<h4 th:case="4" th:utext="${block.data['text']}"></h4>
|
||||
<h5 th:case="5" th:utext="${block.data['text']}"></h5>
|
||||
<h6 th:case="6" th:utext="${block.data['text']}"></h6>
|
||||
<h2 th:case="*" th:utext="${block.data['text']}"></h2>
|
||||
<h1 th:case="1" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h1>
|
||||
<h2 th:case="2" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h2>
|
||||
<h3 th:case="3" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h3>
|
||||
<h4 th:case="4" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h4>
|
||||
<h5 th:case="5" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h5>
|
||||
<h6 th:case="6" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h6>
|
||||
<h2 th:case="*" th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></h2>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- 3. Paragraph Block -->
|
||||
<th:block th:if="${block['type'] == 'paragraph'}">
|
||||
<p th:utext="${block.data['text']}"></p>
|
||||
<p th:utext="${block.data['text']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></p>
|
||||
</th:block>
|
||||
|
||||
<!-- 4. List Block -->
|
||||
<th:block th:if="${block['type'] == 'list'}">
|
||||
<ul th:if="${block.data['style'] == 'unordered'}">
|
||||
<ul th:if="${block.data['style'] == 'unordered'}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
|
||||
<li th:each="item : ${block.data['items']}" th:utext="${item}"></li>
|
||||
</ul>
|
||||
<ol th:if="${block.data['style'] == 'ordered'}">
|
||||
<ol th:if="${block.data['style'] == 'ordered'}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
|
||||
<li th:each="item : ${block.data['items']}" th:utext="${item}"></li>
|
||||
</ol>
|
||||
</th:block>
|
||||
@@ -77,7 +79,7 @@
|
||||
|
||||
<!-- 6. Quote Block -->
|
||||
<th:block th:if="${block['type'] == 'quote'}">
|
||||
<blockquote>
|
||||
<blockquote th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
|
||||
<p th:utext="${block.data['text']}"></p>
|
||||
<footer th:if="${block.data['caption']}" th:utext="${block.data['caption']}"></footer>
|
||||
</blockquote>
|
||||
@@ -90,7 +92,7 @@
|
||||
|
||||
<!-- 8. Table Block -->
|
||||
<th:block th:if="${block['type'] == 'table'}">
|
||||
<table>
|
||||
<table th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
|
||||
<tbody>
|
||||
<tr th:each="row, rowStat : ${block.data['content']}">
|
||||
<th:block th:if="${block.data['withHeadings'] == true and rowStat.index == 0}">
|
||||
@@ -109,9 +111,14 @@
|
||||
<div th:utext="${block.data['html']}" th:remove="tag"></div>
|
||||
</th:block>
|
||||
|
||||
<!-- TinyMCE Rich Editor Block -->
|
||||
<th:block th:if="${block['type'] == 'tinymce'}">
|
||||
<div th:utext="${block.data['html']}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}"></div>
|
||||
</th:block>
|
||||
|
||||
<!-- 10. Accordion Block -->
|
||||
<th:block th:if="${block['type'] == 'accordion'}">
|
||||
<details class="sis-accordion-item mb-3" th:open="${block.data['isOpen'] == true}">
|
||||
<details class="sis-accordion-item mb-3" th:open="${block.data['isOpen'] == true}" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
|
||||
<summary class="sis-accordion-title font-weight-bold p-3 bg-light border rounded" style="cursor: pointer; user-select: none">
|
||||
<span th:utext="${block.data['title']}">Accordion Title</span>
|
||||
</summary>
|
||||
@@ -123,7 +130,7 @@
|
||||
|
||||
<!-- 11. YouTube Video Block -->
|
||||
<th:block th:if="${block['type'] == 'youtube'}">
|
||||
<div class="sis-youtube-container my-4 text-center">
|
||||
<div class="sis-youtube-container my-4 text-center" th:classappend="${block['cssClass']}" th:id="${block['elementId']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
|
||||
<div
|
||||
class="sis-yt-thumb-wrapper"
|
||||
th:data-embed-url="${block.data['url']}"
|
||||
@@ -169,6 +176,8 @@
|
||||
<th:block th:if="${block['type'] == 'hero'}">
|
||||
<div
|
||||
class="sis-hero-banner text-white py-5 px-3 rounded shadow-sm text-center"
|
||||
th:classappend="${block['cssClass']}"
|
||||
th:id="${block['elementId'] != null ? block['elementId'] : null}"
|
||||
style="
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -179,7 +188,8 @@
|
||||
background-position: center;
|
||||
background-color: #002554;
|
||||
"
|
||||
th:styleappend="${'min-height: ' + (block.data['height'] != null and !#strings.isEmpty(block.data['height']) ? block.data['height'] : '350px') + ';' + ((block.data['bgImage'] != null and !#strings.isEmpty(block.data['bgImage'])) ? 'background-image: url(' + block.data['bgImage'] + ');' : 'background: linear-gradient(135deg, #002554 0%, #881C1C 100%);')}"
|
||||
th:styleappend="${'min-height: ' + (block.data['height'] != null and !#strings.isEmpty(block.data['height']) ? block.data['height'] : '350px') + ';' + ((block.data['bgImage'] != null and !#strings.isEmpty(block.data['bgImage'])) ? 'background-image: url(' + block.data['bgImage'] + ');' : 'background: linear-gradient(135deg, #002554 0%, #881C1C 100%);') + (block['customStyle'] != null ? '; ' + block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] : '')}"
|
||||
th:attr="data-custom-attrs=${block['customAttrs']}"
|
||||
>
|
||||
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.4); z-index: 1"></div>
|
||||
<div class="container" style="position: relative; z-index: 2">
|
||||
@@ -210,11 +220,13 @@
|
||||
<!-- 13. Sticky Nav / Sub-menu Block (UMass Theme mc--info-menu style) -->
|
||||
<th:block th:if="${block['type'] == 'stickyNav' or block['type'] == 'nav'}">
|
||||
<nav
|
||||
th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : null}"
|
||||
th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
|
||||
class="mc--menu mc--info-menu sis-sticky-nav my-3"
|
||||
th:classappend="${(block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? (block.data['globalClass'] + ' ') : '') + (block.data['stretched'] == true ? 'sis-fullwidth-breakout ' : '') + (block.data['sticky'] == true or block.data['sticky'] == null ? 'is-sticky ' : '') + (block.data['bgColor'] == '#ffffff' ? 'sis-nav-light ' : '')}"
|
||||
th:classappend="${(block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? (block.data['globalClass'] + ' ') : '') + (block.data['stretched'] == true ? 'sis-fullwidth-breakout ' : '') + (block.data['sticky'] == true or block.data['sticky'] == null ? 'is-sticky ' : '') + (block.data['bgColor'] == '#ffffff' ? 'sis-nav-light ' : '') + (block['cssClass'] != null ? block['cssClass'] : '')}"
|
||||
th:style="${'background-color: ' + (block.data['bgColor'] != null ? block.data['bgColor'] : '#002554') + ' !important;'}"
|
||||
th:data-custom-attrs="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}"
|
||||
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
|
||||
th:attr="data-custom-attrs=${block['customAttrs']}"
|
||||
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}"
|
||||
>
|
||||
<ul class="menu m--menu m--info-menu">
|
||||
<li class="menu-item menu-item--expanded">
|
||||
@@ -268,7 +280,7 @@
|
||||
|
||||
<!-- 14. Timeline Block (Group) -->
|
||||
<th:block th:if="${block['type'] == 'timeline' and block.data['items'] != null}">
|
||||
<section class="sis-timeline" th:id="'timeline-' + ${stat != null ? stat.index : 0}">
|
||||
<section class="sis-timeline" th:id="${block['elementId'] != null ? block['elementId'] : ('timeline-' + (stat != null ? stat.index : 0))}" th:classappend="${block['cssClass']}" th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}" th:attr="data-custom-attrs=${block['customAttrs']}">
|
||||
<div class="sis-timeline__grid">
|
||||
<!-- Text Column (Left) -->
|
||||
<div class="sis-timeline__text-col">
|
||||
@@ -350,15 +362,18 @@
|
||||
|
||||
<!-- 15. Grid / Columns Block -->
|
||||
<th:block th:if="${block['type'] == 'grid'}">
|
||||
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : null}"
|
||||
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : null}"
|
||||
th:data-custom-attrs="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
|
||||
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
|
||||
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : ''}"
|
||||
th:classappend="${block['cssClass']}"
|
||||
th:styleappend="${(block.data['globalStyle'] != null ? block.data['globalStyle'] + ';' : '') + (block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
|
||||
th:attr="data-custom-attrs=${block['customAttrs']}"
|
||||
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
|
||||
<div class="container py-4">
|
||||
<div class="row">
|
||||
<div class="sis-grid-layout" th:classappend="${block.data['itemAlignment'] == 'center' or block.data['centerItems'] == true ? 'justify-content-center align-items-center' : (block.data['itemAlignment'] == 'right' ? 'justify-content-end' : (block.data['itemAlignment'] == 'between' ? 'justify-content-between' : (block.data['itemAlignment'] == 'around' ? 'justify-content-around' : '')))}">
|
||||
<th:block th:each="i : ${#numbers.sequence(1, block.data['cols'])}">
|
||||
<div
|
||||
th:id="${block.data['id' + i] != null and !#strings.isEmpty(block.data['id' + i]) ? block.data['id' + i] : null}"
|
||||
th:class="${(block.data['width' + i] != null and block.data['width' + i] gt 0) ? ('col-lg-' + block.data['width' + i] + ' col-md-6 col-12 mb-4') : (block.data['cols'] == 1 ? 'col-12 mb-4' : (block.data['cols'] == 2 ? 'col-md-6 col-12 mb-4' : (block.data['cols'] == 3 ? 'col-lg-4 col-md-6 col-12 mb-4' : (block.data['cols'] == 4 ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg col-md-6 col-12 mb-4'))))}"
|
||||
th:class="${(block.data['width' + i] != null and block.data['width' + i] gt 0) ? ('col-md-' + block.data['width' + i] + ' sis-grid-span-' + block.data['width' + i]) : (block.data['cols'] == 1 ? 'col-md-12 sis-grid-span-12' : (block.data['cols'] == 2 ? 'col-md-6 sis-grid-span-6' : (block.data['cols'] == 3 ? 'col-md-4 sis-grid-span-4' : (block.data['cols'] == 4 ? 'col-md-3 sis-grid-span-3' : (block.data['cols'] == 6 ? 'col-md-2 sis-grid-span-2' : 'col-md-3 sis-grid-span-3')))))}"
|
||||
th:classappend="${block.data['class' + i] != null and !#strings.isEmpty(block.data['class' + i]) ? block.data['class' + i] : ''}"
|
||||
th:data-custom-attrs="${block.data['attr' + i] != null and !#strings.isEmpty(block.data['attr' + i]) ? block.data['attr' + i] : null}"
|
||||
>
|
||||
@@ -372,9 +387,12 @@
|
||||
|
||||
<!-- 16. Flex Layout Block -->
|
||||
<th:block th:if="${block['type'] == 'flex'}">
|
||||
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : null}"
|
||||
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : null}"
|
||||
th:data-custom-attrs="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
|
||||
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
|
||||
th:class="${block.data['globalClass'] != null and !#strings.isEmpty(block.data['globalClass']) ? block.data['globalClass'] : ''}"
|
||||
th:classappend="${block['cssClass']}"
|
||||
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
|
||||
th:attr="data-custom-attrs=${block['customAttrs']}"
|
||||
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
|
||||
<div class="container py-4">
|
||||
<div
|
||||
style="display: flex; flex-wrap: wrap;"
|
||||
@@ -397,9 +415,12 @@
|
||||
|
||||
<!-- 17. Dedicated Hover Cards Block -->
|
||||
<th:block th:if="${block['type'] == 'hovercard' or block['type'] == 'hover-card' or block['type'] == 'hoverCard'}">
|
||||
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : null}"
|
||||
<div th:id="${block.data['globalId'] != null and !#strings.isEmpty(block.data['globalId']) ? block.data['globalId'] : (block['elementId'] != null ? block['elementId'] : null)}"
|
||||
th:class="${(block.data['styleVariant'] == 'v2' or (block.data['globalClass'] != null and block.data['globalClass'].contains('v2'))) ? ('sis-hover-card-v2-section ' + (block.data['globalClass'] != null ? block.data['globalClass'] : '')) : ('sis-hover-card-section ' + (block.data['globalClass'] != null ? block.data['globalClass'] : ''))}"
|
||||
th:data-custom-attrs="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
|
||||
th:classappend="${block['cssClass']}"
|
||||
th:styleappend="${(block['customStyle'] != null ? block['customStyle'] : '') + (block['alignment'] != null ? '; text-align: ' + block['alignment'] + ';' : '')}"
|
||||
th:attr="data-custom-attrs=${block['customAttrs']}"
|
||||
th:data-custom-attrs-original="${block.data['globalAttributes'] != null and !#strings.isEmpty(block.data['globalAttributes']) ? block.data['globalAttributes'] : (block.data['attributes'] != null and !#strings.isEmpty(block.data['attributes']) ? block.data['attributes'] : null)}">
|
||||
<div class="container py-4">
|
||||
<div class="row align-items-center">
|
||||
<th:block th:each="item, iterStat : ${block.data['items']}">
|
||||
@@ -433,16 +454,28 @@
|
||||
<!-- Type 1: HTML / Text -->
|
||||
<th:block th:if="${type == null or type == 'html'}" th:utext="${blockData != null ? blockData['col' + i] : ''}"></th:block>
|
||||
|
||||
<!-- Type: TinyMCE -->
|
||||
<th:block th:if="${type == 'tinymce' and blockData != null and blockData['col' + i] != null}" th:utext="${blockData['col' + i]['html']}"></th:block>
|
||||
|
||||
<!-- Type 2: Image -->
|
||||
<th:block th:if="${type == 'image'}">
|
||||
<div class="sis-grid-image-wrapper text-center">
|
||||
<!-- Native Image Plugin structure -->
|
||||
<th:block th:if="${blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['file'] != null}">
|
||||
<img th:src="${blockData['col' + i]['file']['url']}" class="img-fluid rounded shadow-sm" th:alt="${blockData['col' + i]['caption']}" />
|
||||
<img th:src="${blockData['col' + i]['file']['url']}"
|
||||
th:class="${(blockData['imgClass' + i] != null and !#strings.isEmpty(blockData['imgClass' + i])) ? blockData['imgClass' + i] : 'img-fluid rounded shadow-sm'}"
|
||||
th:style="${(blockData['imgStyle' + i] != null and !#strings.isEmpty(blockData['imgStyle' + i])) ? blockData['imgStyle' + i] : null}"
|
||||
th:alt="${(blockData['imgAlt' + i] != null and !#strings.isEmpty(blockData['imgAlt' + i])) ? blockData['imgAlt' + i] : (blockData['col' + i]['caption'] != null ? blockData['col' + i]['caption'] : 'Image')}"
|
||||
th:data-attrs="${(blockData['imgAttrs' + i] != null and !#strings.isEmpty(blockData['imgAttrs' + i])) ? blockData['imgAttrs' + i] : null}" />
|
||||
</th:block>
|
||||
<!-- Custom imgUrl fallback -->
|
||||
<th:block th:unless="${blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['file'] != null}">
|
||||
<img th:if="${blockData != null and blockData['imgUrl' + i] != null and !#strings.isEmpty(blockData['imgUrl' + i])}" th:src="${blockData['imgUrl' + i]}" class="img-fluid rounded shadow-sm" alt="Grid Image" />
|
||||
<img th:if="${blockData != null and blockData['imgUrl' + i] != null and !#strings.isEmpty(blockData['imgUrl' + i])}"
|
||||
th:src="${blockData['imgUrl' + i]}"
|
||||
th:class="${(blockData['imgClass' + i] != null and !#strings.isEmpty(blockData['imgClass' + i])) ? blockData['imgClass' + i] : 'img-fluid rounded shadow-sm'}"
|
||||
th:style="${(blockData['imgStyle' + i] != null and !#strings.isEmpty(blockData['imgStyle' + i])) ? blockData['imgStyle' + i] : null}"
|
||||
th:alt="${(blockData['imgAlt' + i] != null and !#strings.isEmpty(blockData['imgAlt' + i])) ? blockData['imgAlt' + i] : 'Grid Image'}"
|
||||
th:data-attrs="${(blockData['imgAttrs' + i] != null and !#strings.isEmpty(blockData['imgAttrs' + i])) ? blockData['imgAttrs' + i] : null}" />
|
||||
</th:block>
|
||||
</div>
|
||||
</th:block>
|
||||
@@ -590,6 +623,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
<!-- Column Caption (Rendered underneath any type) -->
|
||||
<th:block th:with="
|
||||
hasCustomCaption=${blockData != null and blockData['caption' + i] != null and !#strings.isEmpty(blockData['caption' + i])},
|
||||
hasNativeImageCaption=${type == 'image' and blockData != null and blockData['col' + i] != null and blockData['col' + i] != '' and blockData['col' + i]['caption'] != null and !#strings.isEmpty(blockData['col' + i]['caption'])},
|
||||
captionText=${hasCustomCaption ? blockData['caption' + i] : (hasNativeImageCaption ? blockData['col' + i]['caption'] : null)}
|
||||
">
|
||||
<div th:if="${captionText != null and !#strings.isEmpty(#strings.trim(captionText))}"
|
||||
class="sis-grid-cell-caption text-center mt-2 small text-muted font-weight-bold"
|
||||
th:utext="${captionText}">
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- Optional Sub-Plugin Content (Rendered underneath the main cell content) -->
|
||||
<th:block th:if="${blockData != null and blockData['subType' + i] != null and blockData['subType' + i] != 'none' and blockData['subType' + i] != 'caption'}">
|
||||
<div class="sis-grid-sub-content mt-2">
|
||||
<div th:replace=":: renderCell(type=${blockData['subType' + i]}, i=${'sub_' + i}, blockData=${blockData})"></div>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
<style>
|
||||
html {
|
||||
|
||||
@@ -49,11 +49,9 @@
|
||||
</style>
|
||||
<div class="image-video-container has-video">
|
||||
<img
|
||||
src="https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg"
|
||||
src="/uploads/2026/07/dotquy201.webp"
|
||||
alt="Students collaborate using a driving simulator in the UMass Center for Transportation."
|
||||
srcset="
|
||||
https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg?h=9855f42d&itok=d27r8GaT 1920w
|
||||
"
|
||||
srcset="/uploads/2026/07/dotquy201.webp?h=9855f42d&itok=d27r8GaT 1920w"
|
||||
/>
|
||||
<div class="f--ambient-video" id="hero-ambient-video">
|
||||
<video
|
||||
|
||||
+2
-4
@@ -48,11 +48,9 @@
|
||||
</style>
|
||||
<div class="image-video-container has-video">
|
||||
<img
|
||||
src="https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg"
|
||||
src="/uploads/2026/07/dotquy201.webp"
|
||||
alt="Students collaborate using a driving simulator in the UMass Center for Transportation."
|
||||
srcset="
|
||||
https://www.umass.edu/sites/default/files/styles/1_1_1920x1920/public/2025-09/250616_UMASS_4515.jpg?h=9855f42d&itok=d27r8GaT 1920w
|
||||
"
|
||||
srcset="/uploads/2026/07/dotquy201.webp?h=9855f42d&itok=d27r8GaT 1920w"
|
||||
/>
|
||||
<div class="f--ambient-video" id="hero-ambient-video">
|
||||
<video
|
||||
|
||||
Reference in New Issue
Block a user