feat: implement dynamic component template engine with data source integration and management UI
This commit is contained in:
+1369
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
@@ -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 & Minors" target="_self" data-component-id="umass_base:button">
|
||||
<span class="button-text"> Majors & 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 & Costs" target="_self" data-component-id="umass_base:button">
|
||||
<span class="button-text"> Tuition & 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 =====");
|
||||
}
|
||||
}
|
||||
+25
@@ -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();
|
||||
}
|
||||
}
|
||||
+91
@@ -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";
|
||||
});
|
||||
}
|
||||
}
|
||||
+19
-12
@@ -1,20 +1,27 @@
|
||||
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.PageStatus;
|
||||
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.service.PageService;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
/**
|
||||
* Controller for managing CMS Pages in the admin panel.
|
||||
* Provides CRUD operations for static pages.
|
||||
@@ -129,7 +136,7 @@ public class ManagePageController {
|
||||
page.setId(id);
|
||||
pageService.save(page);
|
||||
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)
|
||||
private PostLayout layout = PostLayout.STANDARD;
|
||||
|
||||
@Column(name = "event_time")
|
||||
private java.time.Instant eventTime;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "category_id")
|
||||
private Category category;
|
||||
@@ -143,10 +146,6 @@ public class Post extends AbstractAuditingEntity<Long> {
|
||||
return layout;
|
||||
}
|
||||
|
||||
public void setLayout(PostLayout layout) {
|
||||
this.layout = layout;
|
||||
}
|
||||
|
||||
public Category getCategory() {
|
||||
return category;
|
||||
}
|
||||
@@ -155,6 +154,14 @@ public class Post extends AbstractAuditingEntity<Long> {
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
public java.time.Instant getEventTime() {
|
||||
return eventTime;
|
||||
}
|
||||
|
||||
public void setEventTime(java.time.Instant eventTime) {
|
||||
this.eventTime = eventTime;
|
||||
}
|
||||
|
||||
public Set<Tag> getTags() {
|
||||
return tags;
|
||||
}
|
||||
|
||||
+18
@@ -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.
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
|
||||
+2
-6
@@ -1,7 +1,5 @@
|
||||
package com.sisvietnamvn.web.security;
|
||||
|
||||
import com.sisvietnamvn.web.security.AdminMenuManager;
|
||||
import com.sisvietnamvn.web.security.AdminSettingsManager;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
@@ -15,11 +13,9 @@ import java.util.Map;
|
||||
public class CoreSettingsRegistrar {
|
||||
|
||||
private final AdminSettingsManager settingsManager;
|
||||
private final AdminMenuManager menuManager;
|
||||
|
||||
public CoreSettingsRegistrar(AdminSettingsManager settingsManager, AdminMenuManager menuManager) {
|
||||
public CoreSettingsRegistrar(AdminSettingsManager settingsManager) {
|
||||
this.settingsManager = settingsManager;
|
||||
this.menuManager = menuManager;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
@@ -60,6 +56,6 @@ public class CoreSettingsRegistrar {
|
||||
settingsManager.registerSetting(page, "date_format", "F j, Y");
|
||||
|
||||
// --- Menu Registration for Modules ---
|
||||
menuManager.addMenuPage("HTML Snippets", "Snippets", "manage_options", "snippets", "fas fa-code", 30);
|
||||
// (Snippets menu is now statically defined under Components)
|
||||
}
|
||||
}
|
||||
|
||||
+325
@@ -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");
|
||||
}
|
||||
}
|
||||
+24
-55
@@ -21,13 +21,13 @@ public class HtmlSnippetService {
|
||||
private final HtmlSnippetRepository snippetRepository;
|
||||
private final SettingService settingService;
|
||||
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.settingService = settingService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.menuService = menuService;
|
||||
this.componentTemplateService = componentTemplateService;
|
||||
}
|
||||
|
||||
public List<HtmlSnippet> findAll() {
|
||||
@@ -83,65 +83,34 @@ public class HtmlSnippetService {
|
||||
while (m.find()) {
|
||||
String location = m.group(1);
|
||||
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);
|
||||
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(
|
||||
"(?:\\[|[)component:([a-zA-Z0-9_-]+)(?:\\s+data-source=\"([^\"]+)\")?(?:\\]|])"
|
||||
).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) {
|
||||
return menuService.findByLocation(location).map(menu -> {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
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("");
|
||||
String componentSlug = "dropdown".equalsIgnoreCase(style) ? "info-dropdown" : "cta-buttons";
|
||||
return componentTemplateService.render(componentSlug, "menu:" + location);
|
||||
}
|
||||
|
||||
private String renderWidgets(String area) {
|
||||
|
||||
+67
-4
@@ -129,11 +129,23 @@ public class ImportExportService {
|
||||
Document doc = dBuilder.parse(is);
|
||||
doc.getDocumentElement().normalize();
|
||||
|
||||
if (!"sis_export".equals(doc.getDocumentElement().getNodeName())) {
|
||||
log.warn("Invalid XML root element during import");
|
||||
String rootName = doc.getDocumentElement().getNodeName();
|
||||
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;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error importing from XML", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean importSisFormat(Document doc) {
|
||||
try {
|
||||
// Import Posts
|
||||
NodeList postList = doc.getElementsByTagName("post");
|
||||
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;
|
||||
} catch (Exception e) {
|
||||
log.error("Error importing from XML", e);
|
||||
log.error("Error processing SIS XML format", e);
|
||||
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) {
|
||||
Element el = doc.createElement(tagName);
|
||||
el.appendChild(doc.createTextNode(value != null ? value : ""));
|
||||
|
||||
@@ -1,51 +1,10 @@
|
||||
<div class="content-top">
|
||||
<div class="lc--layout-container lc--full">
|
||||
<div class="l--layout l--full">
|
||||
<div class="lr--layout-region lr--main">
|
||||
<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&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>
|
||||
<body>
|
||||
<div style="display: flex; gap: 40px; margin: 40px 0; padding: 0 20px;">
|
||||
<div style="flex: 2; min-width: 300px;">
|
||||
[component:news-grid data-source="posts:tag=news,limit=4,collection=news_posts"]
|
||||
</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>
|
||||
+32
@@ -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>
|
||||
+15
@@ -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/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/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-constraints-changelog - JHipster will add liquibase constraints 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 class="bg-white py-2 collapse-inner rounded">
|
||||
<h6 class="collapse-header">Custom Components:</h6>
|
||||
<a class="collapse-item" href="#">Buttons</a>
|
||||
<a class="collapse-item" href="#">Cards</a>
|
||||
<a class="collapse-item" th:href="@{/manage/components}">Component Templates</a>
|
||||
<a class="collapse-item" th:href="@{/manage/snippets}">HTML Snippets</a>
|
||||
</div>
|
||||
</div>
|
||||
</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">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<span th:text="${errorMessage}"></span>
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<span th:text="${errorMessage}"></span>
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
-46
@@ -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 & Minors" target="_self" data-component-id="umass_base:button">
|
||||
<span class="button-text"> Majors & 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 & Costs" target="_self" data-component-id="umass_base:button">
|
||||
<span class="button-text"> Tuition & 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>
|
||||
Reference in New Issue
Block a user