feat: implement dynamic component template engine with data source integration and management UI

This commit is contained in:
2026-07-13 21:24:04 +07:00
parent 849c79cc0f
commit 5513448738
23 changed files with 2588 additions and 180 deletions
@@ -0,0 +1,31 @@
<div data-component-id="umass_base:block-multiple-ctas" data-background="solid" data-layout-variant="default">
<div class="container" th:unless="${ctaMenu != null and not #lists.isEmpty(ctaMenu.items)}">
<div class="text-container">
<div data-component-id="umass_base:section-title" class="f--section-title">
<h2>TÌM HIỂU<span class="highlight">CHUYÊN KHOA</span></h2>
</div>
</div>
<div class="links-container">
<div class="f--field f--button">
<a href="https://www.umass.edu/admissions/apply" class="button-text-link button-context-light " aria-label="Apply" target="_self" data-component-id="umass_base:button">
<span class="button-text"> Apply </span>
</a>
</div>
<div class="f--field f--button">
<a href="https://www.umass.edu/gateway/academics/explore-our-programs" class="button-text-link button-context-light " aria-label="Majors &amp; Minors" target="_self" data-component-id="umass_base:button">
<span class="button-text"> Majors &amp; Minors </span>
</a>
</div>
<div class="f--field f--button">
<a href="https://www.umass.edu/admissions/undergraduate-admissions/costs-aid" class="button-text-link button-context-light " aria-label="Tuition &amp; Costs" target="_self" data-component-id="umass_base:button">
<span class="button-text"> Tuition &amp; Costs </span>
</a>
</div>
<div class="f--field f--button">
<a href="https://www.umass.edu/admissions/visit" class="button-text-link button-context-light " aria-label="Take a Tour" target="_self" data-component-id="umass_base:button">
<span class="button-text"> Take a Tour </span>
</a>
</div>
</div>
</div>
</div>
@@ -0,0 +1,110 @@
package com.sisvietnamvn.web;
import com.sisvietnamvn.web.domain.ComponentTemplate;
import com.sisvietnamvn.web.repository.ComponentTemplateRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Component
public class NewsEventsSeeder implements CommandLineRunner {
private final ComponentTemplateRepository repository;
public NewsEventsSeeder(ComponentTemplateRepository repository) {
this.repository = repository;
}
@Override
@Transactional
public void run(String... args) throws Exception {
seedNewsGrid();
seedEventsList();
}
private void seedNewsGrid() {
String slug = "news-grid";
Optional<ComponentTemplate> opt = repository.findBySlug(slug);
ComponentTemplate t = opt.orElse(new ComponentTemplate());
t.setSlug(slug);
t.setName("News Grid");
t.setDescription("Dynamic grid layout for news.");
t.setActive(true);
String html =
"<style>\n" +
" .ne-news-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; font-family: 'Inter', sans-serif; }\n" +
" .ne-news-card { display: flex; border: 1px solid #a91c1c; border-radius: 12px; overflow: hidden; background: #fff; align-items: stretch; }\n" +
" .ne-news-image { flex: 0 0 40%; background-color: #f4f4f4; }\n" +
" .ne-news-image img { width: 100%; height: 100%; object-fit: cover; }\n" +
" .ne-news-content { flex: 1; padding: 15px; display: flex; flex-direction: column; justify-content: center; }\n" +
" .ne-news-label { font-size: 0.7rem; color: #888; background: #eee; padding: 2px 6px; border-radius: 4px; display: inline-block; margin-bottom: 8px; width: fit-content; }\n" +
" .ne-news-title { font-size: 0.95rem; font-weight: 700; color: #333; margin: 0 0 8px 0; line-height: 1.3; }\n" +
" .ne-news-title a { color: inherit; text-decoration: none; }\n" +
" .ne-news-title a:hover { color: #a91c1c; }\n" +
" .ne-news-date { font-size: 0.8rem; color: #a91c1c; font-weight: 600; }\n" +
" @media (max-width: 992px) { .ne-news-grid { grid-template-columns: 1fr; } }\n" +
" @media (max-width: 768px) { .ne-news-card { flex-direction: column; } .ne-news-image { flex: none; height: 180px; } }\n" +
"</style>\n" +
"<div class=\"ne-news-grid\">\n" +
" {{#each news_posts}}\n" +
" <div class=\"ne-news-card\">\n" +
" <div class=\"ne-news-image\"><img src=\"{{featuredImageUrl}}\" alt=\"{{title}}\"></div>\n" +
" <div class=\"ne-news-content\">\n" +
" <span class=\"ne-news-label\">TIN TỨC</span>\n" +
" <h3 class=\"ne-news-title\"><a href=\"/post/{{slug}}\">{{title}}</a></h3>\n" +
" <div class=\"ne-news-date\">{{date}}</div>\n" +
" </div>\n" +
" </div>\n" +
" {{/each}}\n" +
"</div>\n";
t.setHtmlTemplate(html);
repository.save(t);
System.out.println("====== SUCCESS: SEEDED news-grid ComponentTemplate =====");
}
private void seedEventsList() {
String slug = "events-list";
Optional<ComponentTemplate> opt = repository.findBySlug(slug);
ComponentTemplate t = opt.orElse(new ComponentTemplate());
t.setSlug(slug);
t.setName("Events List");
t.setDescription("Vertical list layout for events with event times.");
t.setActive(true);
String html =
"<style>\n" +
" .ne-events-list { display: flex; flex-direction: column; font-family: 'Inter', sans-serif; }\n" +
" .ne-event-item { display: flex; gap: 15px; padding: 15px 0; border-bottom: 1px solid #e0e0e0; align-items: center; }\n" +
" .ne-event-date-box { background: #a91c1c; color: #fff; width: 50px; height: 50px; display: flex; flex-direction: column; align-items: center; justify-content: center; font-weight: bold; line-height: 1.1; }\n" +
" .ne-event-month { font-size: 0.7rem; text-transform: uppercase; }\n" +
" .ne-event-day { font-size: 1.2rem; }\n" +
" .ne-event-details { flex: 1; }\n" +
" .ne-event-title { font-size: 0.9rem; font-weight: 700; color: #222; margin: 0 0 4px 0; }\n" +
" .ne-event-title a { color: inherit; text-decoration: none; }\n" +
" .ne-event-title a:hover { color: #a91c1c; }\n" +
" .ne-event-meta { font-size: 0.8rem; color: #666; margin: 0; }\n" +
"</style>\n" +
"<div class=\"ne-events-list\">\n" +
" {{#each event_posts}}\n" +
" <div class=\"ne-event-item\">\n" +
" <div class=\"ne-event-date-box\">\n" +
" <span class=\"ne-event-month\">{{eventMonth}}</span>\n" +
" <span class=\"ne-event-day\">{{eventDay}}</span>\n" +
" </div>\n" +
" <div class=\"ne-event-details\">\n" +
" <h4 class=\"ne-event-title\"><a href=\"/post/{{slug}}\">{{title}}</a></h4>\n" +
" <p class=\"ne-event-meta\">{{eventTimeStr}}<br>{{excerpt}}</p>\n" +
" </div>\n" +
" </div>\n" +
" {{/each}}\n" +
"</div>\n";
t.setHtmlTemplate(html);
repository.save(t);
System.out.println("====== SUCCESS: SEEDED events-list ComponentTemplate =====");
}
}
@@ -0,0 +1,25 @@
package com.sisvietnamvn.web.config;
import com.sisvietnamvn.web.service.ComponentTemplateService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/**
* Seeds default Component Templates on application startup.
*/
@Component
@Order(10)
public class ComponentTemplateSeeder implements CommandLineRunner {
private final ComponentTemplateService componentTemplateService;
public ComponentTemplateSeeder(ComponentTemplateService componentTemplateService) {
this.componentTemplateService = componentTemplateService;
}
@Override
public void run(String... args) {
componentTemplateService.seedDefaults();
}
}
@@ -0,0 +1,91 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.ComponentTemplate;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.service.ComponentTemplateService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Controller for managing Component Templates in the admin panel.
* Provides CRUD operations for reusable HTML component templates.
*/
@Controller
@RequestMapping("/manage/components")
@PreAuthorize("hasAnyAuthority(\"" + AuthoritiesConstants.ADMIN + "\", \"" + AuthoritiesConstants.EDITOR + "\")")
public class ManageComponentTemplateController {
private final ComponentTemplateService componentTemplateService;
public ManageComponentTemplateController(ComponentTemplateService componentTemplateService) {
this.componentTemplateService = componentTemplateService;
}
@GetMapping
public String listTemplates(Model model) {
model.addAttribute("templates", componentTemplateService.findAll());
return "manage/components/list";
}
@GetMapping("/new")
public String newTemplateForm(Model model) {
model.addAttribute("template", new ComponentTemplate());
model.addAttribute("isNew", true);
return "manage/components/form";
}
@PostMapping("/create")
public String createTemplate(@ModelAttribute ComponentTemplate template, RedirectAttributes redirectAttributes) {
ComponentTemplate saved = componentTemplateService.save(template);
redirectAttributes.addFlashAttribute("successMessage", "Component template created successfully.");
return "redirect:/manage/components/" + saved.getId() + "/edit";
}
@GetMapping("/{id}/edit")
public String editTemplateForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
return componentTemplateService.findById(id).map(template -> {
model.addAttribute("template", template);
model.addAttribute("isNew", false);
return "manage/components/form";
}).orElseGet(() -> {
redirectAttributes.addFlashAttribute("errorMessage", "Component template not found.");
return "redirect:/manage/components";
});
}
@PostMapping("/{id}")
public String updateTemplate(@PathVariable Long id, @ModelAttribute ComponentTemplate template, RedirectAttributes redirectAttributes) {
template.setId(id);
ComponentTemplate saved = componentTemplateService.save(template);
redirectAttributes.addFlashAttribute("successMessage", "Component template updated successfully.");
return "redirect:/manage/components/" + saved.getId() + "/edit";
}
@PostMapping("/{id}/delete")
public String deleteTemplate(@PathVariable Long id, RedirectAttributes redirectAttributes) {
componentTemplateService.deleteById(id);
redirectAttributes.addFlashAttribute("successMessage", "Component template deleted successfully.");
return "redirect:/manage/components";
}
@PostMapping("/{id}/duplicate")
public String duplicateTemplate(@PathVariable Long id, RedirectAttributes redirectAttributes) {
return componentTemplateService.findById(id).map(template -> {
ComponentTemplate duplicated = new ComponentTemplate();
duplicated.setName(template.getName() + " (Copy)");
duplicated.setSlug(template.getSlug() + "-copy-" + System.currentTimeMillis());
duplicated.setHtmlTemplate(template.getHtmlTemplate());
duplicated.setDescription(template.getDescription());
duplicated.setActive(false);
ComponentTemplate saved = componentTemplateService.save(duplicated);
redirectAttributes.addFlashAttribute("successMessage", "Component template duplicated successfully.");
return "redirect:/manage/components/" + saved.getId() + "/edit";
}).orElseGet(() -> {
redirectAttributes.addFlashAttribute("errorMessage", "Component template not found.");
return "redirect:/manage/components";
});
}
}
@@ -1,20 +1,27 @@
package com.sisvietnamvn.web.controller.manage; package com.sisvietnamvn.web.controller.manage;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.sisvietnamvn.web.domain.Page; import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.domain.PageStatus; import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.hook.HookManager; import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.service.PageService;
import jakarta.validation.Valid;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.security.access.prepost.PreAuthorize;
import com.sisvietnamvn.web.security.AuthoritiesConstants; import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.service.PageService;
import jakarta.validation.Valid;
/** /**
* Controller for managing CMS Pages in the admin panel. * Controller for managing CMS Pages in the admin panel.
* Provides CRUD operations for static pages. * Provides CRUD operations for static pages.
@@ -129,7 +136,7 @@ public class ManagePageController {
page.setId(id); page.setId(id);
pageService.save(page); pageService.save(page);
redirectAttributes.addFlashAttribute("successMessage", "Page updated successfully!"); redirectAttributes.addFlashAttribute("successMessage", "Page updated successfully!");
return "redirect:/manage/pages"; return "redirect:/manage/pages/" + page.getId() + "/edit";
} }
/** /**
@@ -0,0 +1,109 @@
package com.sisvietnamvn.web.domain;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.Serial;
import java.util.Objects;
/**
* Entity representing a reusable Component Template.
* Stores HTML templates with {{placeholder}} syntax that can be
* rendered with dynamic data (menus, posts, etc.) via shortcodes.
*/
@Entity
@Table(name = "sis_component_template")
public class ComponentTemplate extends AbstractAuditingEntity<Long> {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
@SequenceGenerator(name = "sequenceGenerator")
private Long id;
@NotNull
@Size(max = 255)
@Column(name = "slug", length = 255, nullable = false, unique = true)
private String slug;
@NotNull
@Size(max = 255)
@Column(name = "name", length = 255, nullable = false)
private String name;
@Column(name = "html_template", columnDefinition = "TEXT")
private String htmlTemplate;
@Size(max = 500)
@Column(name = "description", length = 500)
private String description;
@Column(name = "active")
private boolean active = true;
// Getters and Setters
@Override
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getHtmlTemplate() {
return htmlTemplate;
}
public void setHtmlTemplate(String htmlTemplate) {
this.htmlTemplate = htmlTemplate;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ComponentTemplate that = (ComponentTemplate) o;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
@@ -60,6 +60,9 @@ public class Post extends AbstractAuditingEntity<Long> {
@Column(name = "layout", length = 20, nullable = false) @Column(name = "layout", length = 20, nullable = false)
private PostLayout layout = PostLayout.STANDARD; private PostLayout layout = PostLayout.STANDARD;
@Column(name = "event_time")
private java.time.Instant eventTime;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id") @JoinColumn(name = "category_id")
private Category category; private Category category;
@@ -143,10 +146,6 @@ public class Post extends AbstractAuditingEntity<Long> {
return layout; return layout;
} }
public void setLayout(PostLayout layout) {
this.layout = layout;
}
public Category getCategory() { public Category getCategory() {
return category; return category;
} }
@@ -155,6 +154,14 @@ public class Post extends AbstractAuditingEntity<Long> {
this.category = category; this.category = category;
} }
public java.time.Instant getEventTime() {
return eventTime;
}
public void setEventTime(java.time.Instant eventTime) {
this.eventTime = eventTime;
}
public Set<Tag> getTags() { public Set<Tag> getTags() {
return tags; return tags;
} }
@@ -0,0 +1,18 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.ComponentTemplate;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Spring Data JPA repository for the ComponentTemplate entity.
*/
@Repository
public interface ComponentTemplateRepository extends JpaRepository<ComponentTemplate, Long> {
Optional<ComponentTemplate> findBySlug(String slug);
boolean existsBySlug(String slug);
}
@@ -81,5 +81,5 @@ public interface PostRepository extends JpaRepository<Post, Long> {
* Find all posts that have a specific tag. * Find all posts that have a specific tag.
*/ */
@Query("SELECT p FROM Post p JOIN p.tags t WHERE t.id = :tagId ORDER BY p.createdDate DESC") @Query("SELECT p FROM Post p JOIN p.tags t WHERE t.id = :tagId ORDER BY p.createdDate DESC")
List<Post> findByTagId(@Param("tagId") Long tagId); List<Post> findByTagId(@Param("tagId") Long tagId, org.springframework.data.domain.Pageable pageable);
} }
@@ -1,7 +1,5 @@
package com.sisvietnamvn.web.security; package com.sisvietnamvn.web.security;
import com.sisvietnamvn.web.security.AdminMenuManager;
import com.sisvietnamvn.web.security.AdminSettingsManager;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@@ -15,11 +13,9 @@ import java.util.Map;
public class CoreSettingsRegistrar { public class CoreSettingsRegistrar {
private final AdminSettingsManager settingsManager; private final AdminSettingsManager settingsManager;
private final AdminMenuManager menuManager;
public CoreSettingsRegistrar(AdminSettingsManager settingsManager, AdminMenuManager menuManager) { public CoreSettingsRegistrar(AdminSettingsManager settingsManager) {
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.menuManager = menuManager;
} }
@PostConstruct @PostConstruct
@@ -60,6 +56,6 @@ public class CoreSettingsRegistrar {
settingsManager.registerSetting(page, "date_format", "F j, Y"); settingsManager.registerSetting(page, "date_format", "F j, Y");
// --- Menu Registration for Modules --- // --- Menu Registration for Modules ---
menuManager.addMenuPage("HTML Snippets", "Snippets", "manage_options", "snippets", "fas fa-code", 30); // (Snippets menu is now statically defined under Components)
} }
} }
@@ -0,0 +1,325 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.ComponentTemplate;
import com.sisvietnamvn.web.domain.Menu;
import com.sisvietnamvn.web.domain.MenuItem;
import com.sisvietnamvn.web.repository.ComponentTemplateRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Service for managing Component Templates and rendering them with dynamic data.
*
* <p>Supports a simple built-in template engine with the following syntax:</p>
* <ul>
* <li>{@code {{name}}} — replaced with the data source name</li>
* <li>{@code {{#each items}}...{{/each}}} — loops over items</li>
* <li>{@code {{label}}} — item label (inside loop)</li>
* <li>{@code {{url}}} — item URL (inside loop)</li>
* <li>{@code {{index}}} — zero-based index (inside loop)</li>
* </ul>
*/
@Service
@Transactional
public class ComponentTemplateService {
private static final Logger LOG = LoggerFactory.getLogger(ComponentTemplateService.class);
/**
* Pattern to match {{#each items}}...{{/each}} blocks.
* Uses DOTALL so the dot matches newlines inside the loop body.
*/
private static final Pattern EACH_PATTERN = Pattern.compile(
"\\{\\{#each\\s+([a-zA-Z0-9_]+)\\s*}}(.*?)\\{\\{/each\\s*}}", Pattern.DOTALL);
private final ComponentTemplateRepository templateRepository;
private final MenuService menuService;
private final com.sisvietnamvn.web.repository.PostRepository postRepository;
private final com.sisvietnamvn.web.repository.TagRepository tagRepository;
public ComponentTemplateService(
ComponentTemplateRepository templateRepository,
MenuService menuService,
com.sisvietnamvn.web.repository.PostRepository postRepository,
com.sisvietnamvn.web.repository.TagRepository tagRepository) {
this.templateRepository = templateRepository;
this.menuService = menuService;
this.postRepository = postRepository;
this.tagRepository = tagRepository;
}
// ── CRUD ──────────────────────────────────────────────────────────────
public List<ComponentTemplate> findAll() {
return templateRepository.findAll();
}
public Optional<ComponentTemplate> findById(Long id) {
return templateRepository.findById(id);
}
public Optional<ComponentTemplate> findBySlug(String slug) {
return templateRepository.findBySlug(slug);
}
public ComponentTemplate save(ComponentTemplate template) {
return templateRepository.save(template);
}
public void deleteById(Long id) {
templateRepository.deleteById(id);
}
public boolean existsBySlug(String slug) {
return templateRepository.existsBySlug(slug);
}
// ── Rendering ─────────────────────────────────────────────────────────
/**
* Renders the given component template (by slug) using the optionally
* specified data source.
*
* @param slug the unique slug of the ComponentTemplate
* @param dataSourceSpec the data source specification, e.g. "menu:CTA" or null
* @return the rendered HTML, or empty string if the template is not found/inactive
*/
@Transactional(readOnly = true)
public String render(String slug, String dataSourceSpec) {
Optional<ComponentTemplate> optTemplate = templateRepository.findBySlug(slug);
if (optTemplate.isEmpty() || !optTemplate.get().isActive()) {
LOG.warn("Component template not found or inactive: {}", slug);
return "";
}
String html = optTemplate.get().getHtmlTemplate();
if (html == null || html.isEmpty()) {
return "";
}
String result = html;
// If data source specified, render with data
if (dataSourceSpec != null && !dataSourceSpec.isEmpty()) {
result = renderWithDataSource(html, dataSourceSpec);
}
return "\n<!-- BEGIN Component: " + slug + " -->\n" + result + "\n<!-- END Component: " + slug + " -->\n";
}
/**
* Renders the given HTML template string with data from the specified source.
*/
private String renderWithDataSource(String html, String dataSourceSpec) {
String result = html;
String[] sources = dataSourceSpec.split(";");
for (String source : sources) {
String s = source.trim();
if (s.startsWith("menu:")) {
String location = s.substring("menu:".length()).trim();
result = renderWithMenuData(result, location);
} else if (s.startsWith("posts:")) {
result = renderWithPostsData(result, s.substring("posts:".length()).trim());
} else {
LOG.warn("Unknown data source type: {}", s);
}
}
return result;
}
/**
* Renders the template with data from a Menu entity (looked up by location).
*/
private String renderWithMenuData(String html, String location) {
Optional<Menu> optMenu = menuService.findByLocation(location);
if (optMenu.isEmpty()) {
LOG.warn("Menu not found for location: {}", location);
return "";
}
Menu menu = optMenu.get();
String result = html.replace("{{name}}", menu.getName() != null ? menu.getName() : "");
return processEachBlock(result, "items", menu.getItems(), null);
}
/**
* Renders the template with data from Posts (looked up by tag).
*/
private String renderWithPostsData(String html, String params) {
String tagSlug = null;
int limit = 10;
String collection = "posts";
for (String part : params.split(",")) {
String[] kv = part.split("=");
if (kv.length == 2) {
if ("tag".equals(kv[0].trim())) tagSlug = kv[1].trim();
else if ("limit".equals(kv[0].trim())) {
try { limit = Integer.parseInt(kv[1].trim()); } catch (NumberFormatException ignored) {}
}
else if ("collection".equals(kv[0].trim())) collection = kv[1].trim();
}
}
if (tagSlug == null) return html;
Optional<com.sisvietnamvn.web.domain.Tag> optTag = tagRepository.findBySlug(tagSlug);
if (optTag.isEmpty()) return html;
List<com.sisvietnamvn.web.domain.Post> posts = postRepository.findByTagId(
optTag.get().getId(), org.springframework.data.domain.PageRequest.of(0, limit));
return processEachBlock(html, collection, null, posts);
}
/**
* Processes all {{#each <collectionName>}}...{{/each}} blocks in the template.
*/
private String processEachBlock(String html, String targetCollection, java.util.Set<MenuItem> menuItems, List<com.sisvietnamvn.web.domain.Post> posts) {
Matcher matcher = EACH_PATTERN.matcher(html);
StringBuilder sb = new StringBuilder();
java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("dd/MM/yyyy")
.withZone(java.time.ZoneId.systemDefault());
while (matcher.find()) {
String collectionName = matcher.group(1);
String loopBody = matcher.group(2);
// If the template block doesn't match our target collection, leave it as is
if (!collectionName.equals(targetCollection)) {
matcher.appendReplacement(sb, Matcher.quoteReplacement(matcher.group(0)));
continue;
}
StringBuilder rendered = new StringBuilder();
int index = 0;
if ("items".equals(collectionName) && menuItems != null) {
for (MenuItem item : menuItems) {
if (item.getParent() != null) continue;
String itemHtml = loopBody;
itemHtml = itemHtml.replace("{{label}}", item.getLabel() != null ? item.getLabel() : "");
itemHtml = itemHtml.replace("{{url}}", item.getUrl() != null ? item.getUrl() : "#");
itemHtml = itemHtml.replace("{{index}}", String.valueOf(index));
rendered.append(itemHtml);
index++;
}
} else if (posts != null) {
java.time.format.DateTimeFormatter monthFormatter = java.time.format.DateTimeFormatter.ofPattern("MMM").withZone(java.time.ZoneId.systemDefault());
java.time.format.DateTimeFormatter dayFormatter = java.time.format.DateTimeFormatter.ofPattern("dd").withZone(java.time.ZoneId.systemDefault());
java.time.format.DateTimeFormatter timeFormatter = java.time.format.DateTimeFormatter.ofPattern("hh:mm a").withZone(java.time.ZoneId.systemDefault());
for (com.sisvietnamvn.web.domain.Post post : posts) {
String itemHtml = loopBody;
itemHtml = itemHtml.replace("{{title}}", post.getTitle() != null ? post.getTitle() : "");
itemHtml = itemHtml.replace("{{slug}}", post.getSlug() != null ? post.getSlug() : "");
itemHtml = itemHtml.replace("{{excerpt}}", post.getExcerpt() != null ? post.getExcerpt() : "");
itemHtml = itemHtml.replace("{{featuredImageUrl}}", post.getFeaturedImage() != null ? post.getFeaturedImage() : "");
String dateStr = post.getCreatedDate() != null ? formatter.format(post.getCreatedDate()) : "";
itemHtml = itemHtml.replace("{{date}}", dateStr);
if (post.getEventTime() != null) {
itemHtml = itemHtml.replace("{{eventMonth}}", monthFormatter.format(post.getEventTime()));
itemHtml = itemHtml.replace("{{eventDay}}", dayFormatter.format(post.getEventTime()));
itemHtml = itemHtml.replace("{{eventTimeStr}}", timeFormatter.format(post.getEventTime()));
} else {
itemHtml = itemHtml.replace("{{eventMonth}}", "");
itemHtml = itemHtml.replace("{{eventDay}}", "");
itemHtml = itemHtml.replace("{{eventTimeStr}}", "");
}
itemHtml = itemHtml.replace("{{index}}", String.valueOf(index));
rendered.append(itemHtml);
index++;
}
}
matcher.appendReplacement(sb, Matcher.quoteReplacement(rendered.toString()));
}
matcher.appendTail(sb);
return sb.toString();
}
// ── Seed Data ─────────────────────────────────────────────────────────
/**
* Seeds default component templates if they don't already exist.
* Called from application startup (e.g., ApplicationRunner or @PostConstruct).
*/
public void seedDefaults() {
seedCtaButtons();
seedInfoDropdown();
}
private void seedCtaButtons() {
if (templateRepository.findBySlug("cta-buttons").isPresent()) {
return;
}
ComponentTemplate t = new ComponentTemplate();
t.setSlug("cta-buttons");
t.setName("CTA Buttons");
t.setDescription("Renders menu items as a row of styled CTA buttons (UMass theme).");
t.setActive(true);
t.setHtmlTemplate(
"{{#each items}}\n" +
"<div class=\"f--field f--button\">\n" +
" <a href=\"{{url}}\" class=\"button-text-link button-context-light\"\n" +
" aria-label=\"{{label}}\" target=\"_self\"\n" +
" data-component-id=\"umass_base:button\">\n" +
" <span class=\"button-text\"> {{label}} </span>\n" +
" </a>\n" +
"</div>\n" +
"{{/each}}"
);
templateRepository.save(t);
LOG.info("Seeded default component template: cta-buttons");
}
private void seedInfoDropdown() {
if (templateRepository.findBySlug("info-dropdown").isPresent()) {
return;
}
ComponentTemplate t = new ComponentTemplate();
t.setSlug("info-dropdown");
t.setName("Info Dropdown Menu");
t.setDescription("Renders menu items as a dropdown navigation (UMass Info For style).");
t.setActive(true);
t.setHtmlTemplate(
"<nav class=\"mc--menu mc--info-menu\">\n" +
" <ul class=\"menu m--menu m--info-menu\">\n" +
" <li class=\"menu-item menu-item--expanded\">\n" +
" <details class=\"utility-button-wrapper mc--info-menu-container\">\n" +
" <summary type=\"button\" aria-label=\"Display submenu\" aria-expanded=\"false\" aria-haspopup=\"true\" class=\"utility-button arrow-toggle information-for-toggle\">\n" +
" <span>{{name}}</span>\n" +
" <svg version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" x=\"0px\" y=\"0px\" viewBox=\"0 0 16 7\" width=\"16\" height=\"7\" class=\"arrow\">\n" +
" <polygon fill-rule=\"evenodd\" clip-rule=\"evenodd\" fill=\"#ffffff\" points=\"7,7 0,0 16,0\"></polygon>\n" +
" </svg>\n" +
" </summary>\n" +
" <div class=\"submenu-wrapper\">\n" +
" <ul class=\"submenu\">\n" +
" {{#each items}}\n" +
" <li class=\"menu-item\"><a href=\"{{url}}\">{{label}}</a></li>\n" +
" {{/each}}\n" +
" </ul>\n" +
" </div>\n" +
" </details>\n" +
" <div class=\"submenu-wrapper-desktop\">\n" +
" <ul class=\"submenu\">\n" +
" {{#each items}}\n" +
" <li class=\"menu-item\"><a href=\"{{url}}\">{{label}}</a></li>\n" +
" {{/each}}\n" +
" </ul>\n" +
" </div>\n" +
" </li>\n" +
" </ul>\n" +
"</nav>"
);
templateRepository.save(t);
LOG.info("Seeded default component template: info-dropdown");
}
}
@@ -21,13 +21,13 @@ public class HtmlSnippetService {
private final HtmlSnippetRepository snippetRepository; private final HtmlSnippetRepository snippetRepository;
private final SettingService settingService; private final SettingService settingService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final com.sisvietnamvn.web.service.MenuService menuService; private final ComponentTemplateService componentTemplateService;
public HtmlSnippetService(HtmlSnippetRepository snippetRepository, SettingService settingService, ObjectMapper objectMapper, com.sisvietnamvn.web.service.MenuService menuService) { public HtmlSnippetService(HtmlSnippetRepository snippetRepository, SettingService settingService, ObjectMapper objectMapper, ComponentTemplateService componentTemplateService) {
this.snippetRepository = snippetRepository; this.snippetRepository = snippetRepository;
this.settingService = settingService; this.settingService = settingService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.menuService = menuService; this.componentTemplateService = componentTemplateService;
} }
public List<HtmlSnippet> findAll() { public List<HtmlSnippet> findAll() {
@@ -83,65 +83,34 @@ public class HtmlSnippetService {
while (m.find()) { while (m.find()) {
String location = m.group(1); String location = m.group(1);
String style = m.group(2) != null ? m.group(2) : "button"; String style = m.group(2) != null ? m.group(2) : "button";
m.appendReplacement(sb, renderMenu(location, style)); m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(renderMenu(location, style)));
} }
m.appendTail(sb); m.appendTail(sb);
content = sb.toString(); content = sb.toString();
return content; // Process component template shortcodes:
// [component:SLUG]
// [component:SLUG data-source="menu:CTA"]
// Also supports HTML-escaped brackets
java.util.regex.Matcher cm = java.util.regex.Pattern.compile(
"(?:\\[|&#91;)component:([a-zA-Z0-9_-]+)(?:\\s+data-source=\"([^\"]+)\")?(?:\\]|&#93;)"
).matcher(content);
StringBuffer csb = new StringBuffer();
while (cm.find()) {
String componentSlug = cm.group(1);
String dataSource = cm.group(2);
String rendered = componentTemplateService.render(componentSlug, dataSource);
cm.appendReplacement(csb, java.util.regex.Matcher.quoteReplacement(rendered));
}
cm.appendTail(csb);
content = csb.toString();
return "\n<!-- BEGIN Snippet: " + slug + " -->\n" + content + "\n<!-- END Snippet: " + slug + " -->\n";
} }
private String renderMenu(String location, String style) { private String renderMenu(String location, String style) {
return menuService.findByLocation(location).map(menu -> { String componentSlug = "dropdown".equalsIgnoreCase(style) ? "info-dropdown" : "cta-buttons";
StringBuilder sb = new StringBuilder(); return componentTemplateService.render(componentSlug, "menu:" + location);
if ("dropdown".equalsIgnoreCase(style)) {
sb.append("<nav class=\"mc--menu mc--info-menu\">\n");
sb.append(" <ul class=\"menu m--menu m--info-menu\">\n");
sb.append(" <li class=\"menu-item menu-item--expanded\">\n");
sb.append(" <details class=\"utility-button-wrapper mc--info-menu-container\">\n");
sb.append(" <summary type=\"button\" aria-label=\"Display submenu for Information menu\" aria-expanded=\"false\" aria-haspopup=\"true\" class=\"utility-button arrow-toggle information-for-toggle\">\n");
sb.append(" <span>").append(menu.getName() != null ? menu.getName() : "Info For").append("</span>\n");
sb.append(" <svg version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\" viewBox=\"0 0 16 7\" enable-background=\"new 0 0 16 7\" xml:space=\"preserve\" width=\"16\" height=\"7\" class=\"arrow\">\n");
sb.append(" <polygon fill-rule=\"evenodd\" clip-rule=\"evenodd\" fill=\"#ffffff\" points=\"7,7 0,0 16,0 \"></polygon>\n");
sb.append(" </svg>\n");
sb.append(" </summary>\n");
sb.append(" <div class=\"submenu-wrapper\">\n");
sb.append(" <ul class=\"submenu\">\n");
for (com.sisvietnamvn.web.domain.MenuItem item : menu.getItems()) {
if (item.getParent() == null) {
sb.append(" <li class=\"menu-item\"><a href=\"").append(item.getUrl() != null ? item.getUrl() : "#").append("\">").append(item.getLabel()).append("</a></li>\n");
}
}
sb.append(" </ul>\n");
sb.append(" </div>\n");
sb.append(" </details>\n");
sb.append(" <div class=\"submenu-wrapper-desktop\">\n");
sb.append(" <ul class=\"submenu\">\n");
for (com.sisvietnamvn.web.domain.MenuItem item : menu.getItems()) {
if (item.getParent() == null) {
sb.append(" <li class=\"menu-item\"><a href=\"").append(item.getUrl() != null ? item.getUrl() : "#").append("\">").append(item.getLabel()).append("</a></li>\n");
}
}
sb.append(" </ul>\n");
sb.append(" </div>\n");
sb.append(" </li>\n");
sb.append(" </ul>\n");
sb.append("</nav>\n");
} else {
// Default button style
for (com.sisvietnamvn.web.domain.MenuItem item : menu.getItems()) {
if (item.getParent() == null) {
sb.append("<div class=\"f--field f--button\">\n");
sb.append(" <a href=\"").append(item.getUrl() != null ? item.getUrl() : "#").append("\" class=\"button-text-link button-context-light\" aria-label=\"").append(item.getLabel()).append("\" target=\"_self\">\n");
sb.append(" <span class=\"button-text\"> ").append(item.getLabel()).append(" </span>\n");
sb.append(" </a>\n");
sb.append("</div>\n");
}
}
}
return sb.toString();
}).orElse("");
} }
private String renderWidgets(String area) { private String renderWidgets(String area) {
@@ -129,11 +129,23 @@ public class ImportExportService {
Document doc = dBuilder.parse(is); Document doc = dBuilder.parse(is);
doc.getDocumentElement().normalize(); doc.getDocumentElement().normalize();
if (!"sis_export".equals(doc.getDocumentElement().getNodeName())) { String rootName = doc.getDocumentElement().getNodeName();
log.warn("Invalid XML root element during import"); if ("sis_export".equals(rootName)) {
return importSisFormat(doc);
} else if ("rss".equals(rootName)) {
return importWordPressFormat(doc);
} else {
log.warn("Invalid XML root element during import: {}", rootName);
return false; return false;
} }
} catch (Exception e) {
log.error("Error importing from XML", e);
return false;
}
}
private boolean importSisFormat(Document doc) {
try {
// Import Posts // Import Posts
NodeList postList = doc.getElementsByTagName("post"); NodeList postList = doc.getElementsByTagName("post");
for (int i = 0; i < postList.getLength(); i++) { for (int i = 0; i < postList.getLength(); i++) {
@@ -192,14 +204,65 @@ public class ImportExportService {
} }
} }
// Media files are skipped during import since the actual file data is not present in XML.
return true; return true;
} catch (Exception e) { } catch (Exception e) {
log.error("Error importing from XML", e); log.error("Error processing SIS XML format", e);
return false; return false;
} }
} }
private boolean importWordPressFormat(Document doc) {
try {
NodeList itemList = doc.getElementsByTagName("item");
for (int i = 0; i < itemList.getLength(); i++) {
Node node = itemList.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element el = (Element) node;
String postType = getElementValue(el, "wp:post_type");
String title = getElementValue(el, "title");
String slug = getElementValue(el, "wp:post_name");
String content = getElementValue(el, "content:encoded");
String excerpt = getElementValue(el, "excerpt:encoded");
String statusStr = getElementValue(el, "wp:status");
PageStatus status = PageStatus.DRAFT;
if ("publish".equalsIgnoreCase(statusStr)) {
status = PageStatus.PUBLISHED;
} else if ("trash".equalsIgnoreCase(statusStr)) {
status = PageStatus.ARCHIVED;
}
if ("post".equalsIgnoreCase(postType)) {
if (postRepository.findBySlug(slug).isEmpty()) {
Post post = new Post();
post.setTitle(title);
post.setSlug(slug);
post.setContent(content);
post.setExcerpt(excerpt);
post.setStatus(status);
postRepository.save(post);
}
} else if ("page".equalsIgnoreCase(postType)) {
if (pageRepository.findBySlug(slug).isEmpty()) {
Page page = new Page();
page.setTitle(title);
page.setSlug(slug);
page.setContent(content);
page.setStatus(status);
pageRepository.save(page);
}
}
// Ignoring attachments or custom post types for now
}
}
return true;
} catch (Exception e) {
log.error("Error processing WordPress WXR format", e);
return false;
}
}
private void addElement(Document doc, Element parent, String tagName, String value) { private void addElement(Document doc, Element parent, String tagName, String value) {
Element el = doc.createElement(tagName); Element el = doc.createElement(tagName);
el.appendChild(doc.createTextNode(value != null ? value : "")); el.appendChild(doc.createTextNode(value != null ? value : ""));
@@ -1,51 +1,10 @@
<div class="content-top"> <body>
<div class="lc--layout-container lc--full"> <div style="display: flex; gap: 40px; margin: 40px 0; padding: 0 20px;">
<div class="l--layout l--full"> <div style="flex: 2; min-width: 300px;">
<div class="lr--layout-region lr--main"> [component:news-grid data-source="posts:tag=news,limit=4,collection=news_posts"]
<div class="cc--component-container cc--homepage-hero ">
<div class="c--component c--homepage-hero">
<div class="slides-container">
<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" 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">
<div class="f--ambient-video">
<video role="presentation" tabindex="-1" loop="" autoplay="" playsinline="" muted="">
<source src="http://localhost:8080/uploads/2026/07/9375f45b-027a-4fb4-bd18-66889309280d.mp4" type="video/mp4">
</video>
</div>
<div class="video-controls" data-once="ambientVideo">
<div class="video-controls-inner">
<button aria-labelledby="pauseBtn" class="video-button video-pause-button">
<svg height="14" viewBox="0 0 10 14" width="10" xmlns="http://www.w3.org/2000/svg">
<title id="pauseBtn">Pause Background Video</title>
<path d="m1143 711v14h-3v-14zm7 0v14h-3v-14z" fill="#fff" fill-rule="evenodd" transform="translate(-1140 -711)"></path>
</svg>
</button>
<button aria-labelledby="playBtn" class="video-button video-play-button">
<svg height="29" viewBox="0 0 29 29" width="29" xmlns="http://www.w3.org/2000/svg">
<title id="playBtn">Play Background Video</title>
<path d="m17.5 3c-7.99789474 0-14.5 6.50210526-14.5 14.5 0 7.9978947 6.50210526 14.5 14.5 14.5 7.9978947 0 14.5-6.5021053 14.5-14.5 0-7.99789474-6.5021053-14.5-14.5-14.5zm5.6763936 15.2939067-7.3503366 4.5939604c-.6635721.408352-1.5313202-.051044-1.5313202-.8422261v-9.1879207c0-.7911821.8677481-1.2761001 1.5313202-.8422261l7.3503366 4.5939604c.612528.38283.612528 1.3016221 0 1.6844521z" fill="#fff" fill-rule="evenodd" transform="translate(-3 -3)"></path>
</svg>
</button>
</div>
</div>
</div>
<div class="text-container centered">
<div class="slide-text-container-inner">
<h3>
<a href="https://www.umass.edu/gateway/why-umass">Stroke International School 2026.</a>
</h3>
<div class="f--field f--button">
<a href="https://www.umass.edu/gateway/why-umass" class="button-secondary button-context-dark " aria-label="Learn More Learn more about UMass Amherst." data-component-id="umass_base:button">
<span class="button-text"> Learn More </span>
</a>
</div>
[menu:INFO:dropdown]
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> chỉnh <div style="flex: 1; min-width: 300px;">
[component:events-list data-source="posts:tag=event,limit=4,collection=event_posts"]
</div>
</div>
</body>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260713201500-1" author="sisvietnam">
<createTable tableName="sis_component_template">
<column name="id" type="bigint">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="slug" type="varchar(255)">
<constraints nullable="false" unique="true" uniqueConstraintName="ux_sis_component_template_slug"/>
</column>
<column name="name" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="description" type="varchar(1024)"/>
<column name="html_template" type="${clobType}"/>
<column name="active" type="boolean">
<constraints nullable="false"/>
</column>
<column name="created_by" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="created_date" type="timestamp"/>
<column name="last_modified_by" type="varchar(50)"/>
<column name="last_modified_date" type="timestamp"/>
</createTable>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260713211700-1" author="antigravity">
<addColumn tableName="sis_post">
<column name="event_time" type="${datetimeType}">
<constraints nullable="true" />
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -31,6 +31,8 @@
<include file="config/liquibase/changelog/20260702153000_add_setting_entity.xml" relativeToChangelogFile="false"/> <include file="config/liquibase/changelog/20260702153000_add_setting_entity.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260702184000_add_menu_entity.xml" relativeToChangelogFile="false"/> <include file="config/liquibase/changelog/20260702184000_add_menu_entity.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260706081000_add_html_snippet_entity.xml" relativeToChangelogFile="false"/> <include file="config/liquibase/changelog/20260706081000_add_html_snippet_entity.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260713201500_add_component_template_entity.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260713211700_add_event_time_to_post.xml" relativeToChangelogFile="false"/>
<!-- jhipster-needle-liquibase-add-changelog - JHipster will add liquibase changelogs here --> <!-- jhipster-needle-liquibase-add-changelog - JHipster will add liquibase changelogs here -->
<!-- jhipster-needle-liquibase-add-constraints-changelog - JHipster will add liquibase constraints changelogs here --> <!-- jhipster-needle-liquibase-add-constraints-changelog - JHipster will add liquibase constraints changelogs here -->
<!-- jhipster-needle-liquibase-add-incremental-changelog - JHipster will add incremental liquibase changelogs here --> <!-- jhipster-needle-liquibase-add-incremental-changelog - JHipster will add incremental liquibase changelogs here -->
@@ -201,8 +201,8 @@
<div id="collapseTwo" class="collapse" aria-labelledby="headingTwo" data-parent="#accordionSidebar"> <div id="collapseTwo" class="collapse" aria-labelledby="headingTwo" data-parent="#accordionSidebar">
<div class="bg-white py-2 collapse-inner rounded"> <div class="bg-white py-2 collapse-inner rounded">
<h6 class="collapse-header">Custom Components:</h6> <h6 class="collapse-header">Custom Components:</h6>
<a class="collapse-item" href="#">Buttons</a> <a class="collapse-item" th:href="@{/manage/components}">Component Templates</a>
<a class="collapse-item" href="#">Cards</a> <a class="collapse-item" th:href="@{/manage/snippets}">HTML Snippets</a>
</div> </div>
</div> </div>
</li> </li>
@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/manage-layout}">
<head>
<title th:text="${isNew} ? 'New Component Template' : 'Edit Component Template'">Edit Component Template</title>
<style>
#monaco-container {
width: 100%;
height: 500px;
border: 1px solid #d1d3e2;
border-radius: 0.35rem;
overflow: hidden;
}
.placeholder-tag {
display: inline-block;
padding: 2px 8px;
margin: 2px;
border-radius: 3px;
background: #e3f2fd;
border: 1px solid #90caf9;
color: #1565c0;
cursor: pointer;
font-family: monospace;
font-size: 0.85rem;
transition: all 0.2s;
}
.placeholder-tag:hover {
background: #bbdefb;
border-color: #64b5f6;
}
.placeholder-section {
margin-bottom: 0.75rem;
}
.placeholder-section h6 {
font-size: 0.75rem;
text-transform: uppercase;
color: #858796;
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<section layout:fragment="content">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800" th:text="${isNew} ? 'New Component Template' : 'Edit Component Template'">Component Template</h1>
<a th:href="@{/manage/components}" class="d-none d-sm-inline-block btn btn-sm btn-secondary shadow-sm">
<i class="fas fa-arrow-left fa-sm text-white-50"></i> Back to Templates
</a>
</div>
<!-- Alerts -->
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${successMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form th:action="${isNew} ? @{/manage/components/create} : @{/manage/components/{id}(id=${template.id})}" method="post" id="templateForm">
<div class="row">
<!-- Left Column: Properties -->
<div class="col-lg-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Template Properties</h6>
</div>
<div class="card-body">
<div class="form-group">
<label for="name">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" th:value="${template.name}" required placeholder="e.g. CTA Buttons">
</div>
<div class="form-group">
<label for="slug">Slug <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="slug" name="slug" th:value="${template.slug}" required placeholder="e.g. cta-buttons"
pattern="[a-zA-Z0-9_-]+" title="Only letters, numbers, hyphens, and underscores">
<small class="form-text text-muted">Used in shortcode: <code>[component:<span id="slugPreview">slug</span>]</code></small>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea class="form-control" id="description" name="description" rows="3" th:text="${template.description}" placeholder="Brief description of what this template renders"></textarea>
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="active" name="active" th:checked="${template.active}">
<label class="custom-control-label" for="active">Active</label>
</div>
</div>
</div>
</div>
<!-- Available Placeholders -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Available Placeholders</h6>
</div>
<div class="card-body">
<div class="placeholder-section">
<h6>Data Source Fields</h6>
<span class="placeholder-tag" onclick="insertPlaceholder('{{name}}')">{{name}}</span>
</div>
<div class="placeholder-section">
<h6>Loop (repeats for each item)</h6>
<span class="placeholder-tag" onclick="insertPlaceholder('{{#each items}}\n\n{{/each}}')">{{#each items}}</span>
</div>
<div class="placeholder-section">
<h6>Item Fields (inside loop)</h6>
<span class="placeholder-tag" onclick="insertPlaceholder('{{label}}')">{{label}}</span>
<span class="placeholder-tag" onclick="insertPlaceholder('{{url}}')">{{url}}</span>
<span class="placeholder-tag" onclick="insertPlaceholder('{{index}}')">{{index}}</span>
</div>
<hr>
<div class="small text-muted">
<strong>Tip:</strong> Click a placeholder tag to insert it at the cursor position in the editor.
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-block mb-4">
<i class="fas fa-save mr-1"></i>
<span th:text="${isNew} ? 'Create Template' : 'Update Template'">Save</span>
</button>
</div>
<!-- Right Column: HTML Template Editor -->
<div class="col-lg-8">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">HTML Template</h6>
</div>
<div class="card-body p-0">
<textarea id="htmlTemplate" name="htmlTemplate" style="display:none;" th:text="${template.htmlTemplate}"></textarea>
<div id="monaco-container"></div>
</div>
</div>
<!-- Usage Example -->
<div class="card border-left-success shadow mb-4" th:unless="${isNew}">
<div class="card-body">
<div class="text-xs font-weight-bold text-success text-uppercase mb-2">Usage in Snippets</div>
<div class="mb-2">
<strong>Static (no data):</strong><br>
<code>[component:<span th:text="${template.slug}">slug</span>]</code>
</div>
<div class="mb-2">
<strong>With Menu data:</strong><br>
<code>[component:<span th:text="${template.slug}">slug</span> data-source="menu:LOCATION"]</code>
</div>
<div class="small text-muted mt-2">
Replace <code>LOCATION</code> with the menu location code (e.g. CTA, INFO, PRIMARY, FOOTER).
</div>
</div>
</div>
</div>
</div>
</form>
</section>
<section layout:fragment="scripts">
<!-- Monaco Editor -->
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
<script th:inline="javascript">
var monacoEditor = null;
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
require(['vs/editor/editor.main'], function () {
var textArea = document.getElementById('htmlTemplate');
var initialValue = textArea.value || '';
monacoEditor = monaco.editor.create(document.getElementById('monaco-container'), {
value: initialValue,
language: 'html',
theme: 'vs-dark',
minimap: { enabled: false },
automaticLayout: true,
wordWrap: 'on',
fontSize: 14,
lineNumbers: 'on',
renderWhitespace: 'selection',
scrollBeyondLastLine: false,
tabSize: 2
});
// Sync editor content to textarea on form submit
document.getElementById('templateForm').addEventListener('submit', function () {
textArea.value = monacoEditor.getValue();
});
});
// Insert placeholder at cursor position in Monaco
function insertPlaceholder(text) {
if (monacoEditor) {
var selection = monacoEditor.getSelection();
monacoEditor.executeEdits('placeholder', [{
range: selection,
text: text,
forceMoveMarkers: true
}]);
monacoEditor.focus();
}
}
// Update slug preview
document.getElementById('slug').addEventListener('input', function () {
document.getElementById('slugPreview').textContent = this.value || 'slug';
});
// Initialize preview
document.addEventListener('DOMContentLoaded', function () {
var slugInput = document.getElementById('slug');
if (slugInput.value) {
document.getElementById('slugPreview').textContent = slugInput.value;
}
});
</script>
</section>
</body>
</html>
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/manage-layout}">
<head>
<title>Component Templates</title>
</head>
<body>
<div layout:fragment="content">
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Component Templates</h1>
<a th:href="@{/manage/components/new}" class="d-none d-sm-inline-block btn btn-sm btn-primary shadow-sm">
<i class="fas fa-plus fa-sm text-white-50"></i> Add New Template
</a>
</div>
<!-- Alerts -->
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${successMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<!-- Info Card -->
<div class="card border-left-info shadow mb-4">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">How to use</div>
<div class="text-gray-800">
Use shortcode <code>[component:SLUG data-source="menu:LOCATION"]</code> inside any Snippet to render a Component Template with dynamic data.
</div>
</div>
<div class="col-auto">
<i class="fas fa-puzzle-piece fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" width="100%" cellspacing="0">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Description</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="t : ${templates}">
<td th:text="${t.name}">Name</td>
<td>
<code th:text="${t.slug}">slug</code>
<div class="small text-muted mt-1">
Shortcode: <code>[component:<span th:text="${t.slug}">slug</span>]</code>
</div>
</td>
<td>
<small th:text="${t.description}" class="text-muted">Description</small>
</td>
<td>
<span class="badge badge-success" th:if="${t.active}">Active</span>
<span class="badge badge-secondary" th:unless="${t.active}">Inactive</span>
</td>
<td>
<a th:href="@{/manage/components/{id}/edit(id=${t.id})}" class="btn btn-sm btn-outline-info mr-1" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form th:action="@{/manage/components/{id}/duplicate(id=${t.id})}" method="post" style="display:inline;">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<button type="submit" class="btn btn-sm btn-outline-warning mr-1" title="Duplicate">
<i class="fas fa-copy"></i>
</button>
</form>
<form th:action="@{/manage/components/{id}/delete(id=${t.id})}" method="post" style="display:inline;" onsubmit="return confirm('Are you sure you want to delete this component template?');">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(templates)}">
<td colspan="5" class="text-center">No Component Templates found.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -1,46 +0,0 @@
<div data-component-id="umass_base:block-multiple-ctas" data-background="solid" data-layout-variant="default">
<div class="container" th:if="${ctaMenu != null and not #lists.isEmpty(ctaMenu.items)}">
<div class="text-container">
<div data-component-id="umass_base:section-title" class="f--section-title">
<h2><span th:utext="${ctaMenu.name}">TÌM HIỂU CHUYÊN KHOA</span></h2>
</div>
</div>
<div class="links-container">
<div class="f--field f--button" th:each="item : ${ctaMenu.items}">
<a th:href="${item.url}" class="button-text-link button-context-light " th:aria-label="${item.label}" target="_self" data-component-id="umass_base:button">
<span class="button-text" th:text="${item.label}"> Apply </span>
</a>
</div>
</div>
</div>
<!-- Fallback if menu is empty, show default CTA block -->
<div class="container" th:unless="${ctaMenu != null and not #lists.isEmpty(ctaMenu.items)}">
<div class="text-container">
<div data-component-id="umass_base:section-title" class="f--section-title">
<h2>TÌM HIỂU<span class="highlight">CHUYÊN KHOA</span></h2>
</div>
</div>
<div class="links-container">
<div class="f--field f--button">
<a href="https://www.umass.edu/admissions/apply" class="button-text-link button-context-light " aria-label="Apply" target="_self" data-component-id="umass_base:button">
<span class="button-text"> Apply </span>
</a>
</div>
<div class="f--field f--button">
<a href="https://www.umass.edu/gateway/academics/explore-our-programs" class="button-text-link button-context-light " aria-label="Majors &amp; Minors" target="_self" data-component-id="umass_base:button">
<span class="button-text"> Majors &amp; Minors </span>
</a>
</div>
<div class="f--field f--button">
<a href="https://www.umass.edu/admissions/undergraduate-admissions/costs-aid" class="button-text-link button-context-light " aria-label="Tuition &amp; Costs" target="_self" data-component-id="umass_base:button">
<span class="button-text"> Tuition &amp; Costs </span>
</a>
</div>
<div class="f--field f--button">
<a href="https://www.umass.edu/admissions/visit" class="button-text-link button-context-light " aria-label="Take a Tour" target="_self" data-component-id="umass_base:button">
<span class="button-text"> Take a Tour </span>
</a>
</div>
</div>
</div>
</div>