feat: enhance page block system with custom styling support and add TinyMCE rich editor integration

This commit is contained in:
2026-08-03 16:00:20 +07:00
parent 0f4a7dd5b5
commit 48a2cd02f1
107 changed files with 10896 additions and 822 deletions
@@ -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 ===");
}
}
@@ -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) {
@@ -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">&times;</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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
},
_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">&times;</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">&times;</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">&times;</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&amp;itok=d27r8GaT 1920w
"
srcset="/uploads/2026/07/dotquy201.webp?h=9855f42d&amp;itok=d27r8GaT 1920w"
/>
<div class="f--ambient-video" id="hero-ambient-video">
<video
@@ -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&amp;itok=d27r8GaT 1920w
"
srcset="/uploads/2026/07/dotquy201.webp?h=9855f42d&amp;itok=d27r8GaT 1920w"
/>
<div class="f--ambient-video" id="hero-ambient-video">
<video