hoàn thành block editor của Pages
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
package com.sisvietnamvn.web.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
@Configuration
|
||||
public class MvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
exposeDirectory("uploads", registry);
|
||||
}
|
||||
|
||||
private void exposeDirectory(String dirName, ResourceHandlerRegistry registry) {
|
||||
Path uploadDir = Paths.get(dirName);
|
||||
String uploadPath = uploadDir.toFile().getAbsolutePath();
|
||||
|
||||
if (dirName.startsWith("../")) {
|
||||
dirName = dirName.replace("../", "");
|
||||
}
|
||||
|
||||
registry.addResourceHandler("/" + dirName + "/**")
|
||||
.addResourceLocations("file:/" + uploadPath + "/");
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -46,8 +46,9 @@ public class SecurityConfiguration {
|
||||
authz
|
||||
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
|
||||
"/manage/**", "/css/**", "/images/**", "/js/**", "/UMass*/**", "/Undergraduate*/**",
|
||||
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**")
|
||||
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/api/manage/snippets/**", "/page/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/manage/**", "/api/manage/media/upload").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/authenticate").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/authenticate").permitAll()
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
import com.sisvietnamvn.web.hook.HookManager;
|
||||
import tech.jhipster.config.JHipsterConstants;
|
||||
import tech.jhipster.config.JHipsterProperties;
|
||||
import tech.jhipster.config.h2.H2ConfigurationHelper;
|
||||
@@ -26,12 +27,13 @@ public class WebConfigurer implements ServletContextInitializer {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(WebConfigurer.class);
|
||||
|
||||
private final Environment env;
|
||||
|
||||
private final JHipsterProperties jHipsterProperties;
|
||||
private final HookManager hookManager;
|
||||
|
||||
public WebConfigurer(Environment env, JHipsterProperties jHipsterProperties) {
|
||||
public WebConfigurer(Environment env, JHipsterProperties jHipsterProperties, HookManager hookManager) {
|
||||
this.env = env;
|
||||
this.jHipsterProperties = jHipsterProperties;
|
||||
this.hookManager = hookManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -44,6 +46,7 @@ public class WebConfigurer implements ServletContextInitializer {
|
||||
initH2Console(servletContext);
|
||||
}
|
||||
LOG.info("Web application fully configured");
|
||||
hookManager.doAction("init");
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.sisvietnamvn.web.controller;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.domain.Page;
|
||||
import com.sisvietnamvn.web.security.AuthoritiesConstants;
|
||||
import com.sisvietnamvn.web.security.SecurityUtils;
|
||||
import com.sisvietnamvn.web.service.PageService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Controller for rendering public-facing pages dynamically.
|
||||
*/
|
||||
@Controller
|
||||
public class PageController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(PageController.class);
|
||||
|
||||
private final PageService pageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public PageController(PageService pageService, ObjectMapper objectMapper) {
|
||||
this.pageService = pageService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /page/{slug} : Render a page dynamically by slug.
|
||||
*/
|
||||
@GetMapping("/page/{slug}")
|
||||
public String getPage(@PathVariable("slug") String slug, Model model) {
|
||||
LOG.debug("REST request to get public Page : {}", slug);
|
||||
|
||||
Optional<Page> pageOpt = pageService.findBySlug(slug);
|
||||
|
||||
if (pageOpt.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Page not found");
|
||||
}
|
||||
|
||||
Page page = pageOpt.get();
|
||||
|
||||
// Security Check: If Draft, only Admins can view
|
||||
if ("DRAFT".equals(page.getStatus()) && !SecurityUtils.hasCurrentUserThisAuthority(AuthoritiesConstants.ADMIN)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Page not found");
|
||||
}
|
||||
|
||||
// Parse Editor.js content JSON to extract blocks
|
||||
List<Map<String, Object>> blocks = Collections.emptyList();
|
||||
if (page.getContent() != null && !page.getContent().trim().isEmpty()) {
|
||||
try {
|
||||
// Editor.js JSON has a "blocks" array at the root level
|
||||
Map<String, Object> editorData = objectMapper.readValue(page.getContent(), new TypeReference<>() {});
|
||||
if (editorData.containsKey("blocks")) {
|
||||
blocks = (List<Map<String, Object>>) editorData.get("blocks");
|
||||
}
|
||||
} catch (JsonProcessingException e) {
|
||||
LOG.error("Failed to parse Editor.js JSON for page slug: {}", slug, e);
|
||||
}
|
||||
}
|
||||
|
||||
model.addAttribute("page", page);
|
||||
model.addAttribute("blocks", blocks);
|
||||
|
||||
return "page";
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -1,16 +1,19 @@
|
||||
package com.sisvietnamvn.web.controller;
|
||||
package com.sisvietnamvn.web.controller.manage;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
/**
|
||||
* Controller for the admin dashboard index page.
|
||||
* Serves the main "/manage" landing page.
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/manage")
|
||||
public class ManageController {
|
||||
public class ManageDashboardController {
|
||||
|
||||
@GetMapping({"", "/"})
|
||||
public String index() {
|
||||
return "manage/index";
|
||||
}
|
||||
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package com.sisvietnamvn.web.controller.manage;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Controller for managing CMS Pages in the admin panel.
|
||||
* Provides CRUD operations for static pages.
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/manage/pages")
|
||||
public class ManagePageController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ManagePageController.class);
|
||||
|
||||
private final PageService pageService;
|
||||
private final HookManager hookManager;
|
||||
|
||||
public ManagePageController(PageService pageService, HookManager hookManager) {
|
||||
this.pageService = pageService;
|
||||
this.hookManager = hookManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered before any handler method in this controller.
|
||||
*/
|
||||
@ModelAttribute
|
||||
public void adminInit() {
|
||||
hookManager.doAction("admin_init");
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /manage/pages — List all pages.
|
||||
*/
|
||||
@GetMapping
|
||||
public String listPages(Model model) {
|
||||
LOG.debug("Request to list all pages");
|
||||
model.addAttribute("pages", pageService.findAll());
|
||||
return "manage/pages/list";
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /manage/pages/new — Show the "Add New Page" form.
|
||||
*/
|
||||
@GetMapping("/new")
|
||||
public String showCreateForm(Model model) {
|
||||
LOG.debug("Request to show create page form");
|
||||
Page page = new Page();
|
||||
page.setStatus(PageStatus.DRAFT);
|
||||
page.setDisplayOrder(0);
|
||||
model.addAttribute("page", page);
|
||||
model.addAttribute("statuses", PageStatus.values());
|
||||
model.addAttribute("isNew", true);
|
||||
return "manage/pages/form";
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /manage/pages — Save a new page.
|
||||
*/
|
||||
@PostMapping
|
||||
public String createPage(@Valid @ModelAttribute("page") Page page,
|
||||
BindingResult bindingResult,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to create Page : {}", page);
|
||||
if (bindingResult.hasErrors()) {
|
||||
model.addAttribute("statuses", PageStatus.values());
|
||||
model.addAttribute("isNew", true);
|
||||
return "manage/pages/form";
|
||||
}
|
||||
pageService.save(page);
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Page created successfully!");
|
||||
return "redirect:/manage/pages";
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /manage/pages/{id}/edit — Show the "Edit Page" form.
|
||||
*/
|
||||
@GetMapping("/{id}/edit")
|
||||
public String showEditForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to show edit form for Page : {}", id);
|
||||
Optional<Page> pageOptional = pageService.findById(id);
|
||||
if (pageOptional.isEmpty()) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Page not found.");
|
||||
return "redirect:/manage/pages";
|
||||
}
|
||||
model.addAttribute("page", pageOptional.get());
|
||||
model.addAttribute("statuses", PageStatus.values());
|
||||
model.addAttribute("isNew", false);
|
||||
return "manage/pages/form";
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /manage/pages/{id} — Update an existing page.
|
||||
*/
|
||||
@PostMapping("/{id}")
|
||||
public String updatePage(@PathVariable Long id,
|
||||
@Valid @ModelAttribute("page") Page page,
|
||||
BindingResult bindingResult,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to update Page : {}", id);
|
||||
if (bindingResult.hasErrors()) {
|
||||
model.addAttribute("statuses", PageStatus.values());
|
||||
model.addAttribute("isNew", false);
|
||||
return "manage/pages/form";
|
||||
}
|
||||
page.setId(id);
|
||||
pageService.save(page);
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Page updated successfully!");
|
||||
return "redirect:/manage/pages";
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /manage/pages/{id}/delete — Delete a page.
|
||||
*/
|
||||
@PostMapping("/{id}/delete")
|
||||
public String deletePage(@PathVariable Long id, RedirectAttributes redirectAttributes) {
|
||||
LOG.debug("Request to delete Page : {}", id);
|
||||
pageService.delete(id);
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Page deleted successfully!");
|
||||
return "redirect:/manage/pages";
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.sisvietnamvn.web.controller.manage;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/manage/media")
|
||||
public class MediaController {
|
||||
|
||||
private final Logger log = LoggerFactory.getLogger(MediaController.class);
|
||||
|
||||
// Save files in an 'uploads' directory at the project root
|
||||
private final String UPLOAD_DIR = "uploads/";
|
||||
|
||||
@PostMapping("/upload")
|
||||
public ResponseEntity<Map<String, Object>> uploadFile(@RequestParam("file") MultipartFile file) {
|
||||
log.debug("REST request to upload file: {}", file.getOriginalFilename());
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
if (file == null || file.isEmpty()) {
|
||||
response.put("success", 0);
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure the upload directory exists
|
||||
Path uploadPath = Paths.get(UPLOAD_DIR);
|
||||
if (!Files.exists(uploadPath)) {
|
||||
Files.createDirectories(uploadPath);
|
||||
}
|
||||
|
||||
// Generate a unique file name to avoid conflicts
|
||||
String originalFilename = StringUtils.cleanPath(file.getOriginalFilename());
|
||||
String extension = "";
|
||||
int dotIndex = originalFilename.lastIndexOf('.');
|
||||
if (dotIndex > 0) {
|
||||
extension = originalFilename.substring(dotIndex);
|
||||
}
|
||||
String newFilename = UUID.randomUUID().toString() + extension;
|
||||
|
||||
// Save the file
|
||||
Path filePath = uploadPath.resolve(newFilename);
|
||||
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
// Construct the Editor.js expected response
|
||||
Map<String, Object> fileData = new HashMap<>();
|
||||
fileData.put("url", "/uploads/" + newFilename);
|
||||
fileData.put("name", originalFilename);
|
||||
fileData.put("size", file.getSize());
|
||||
if (extension.startsWith(".")) {
|
||||
fileData.put("extension", extension.substring(1));
|
||||
}
|
||||
|
||||
response.put("success", 1);
|
||||
response.put("file", fileData);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to store uploaded file", e);
|
||||
response.put("success", 0);
|
||||
return ResponseEntity.internalServerError().body(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.sisvietnamvn.web.controller.manage;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* REST controller for fetching predefined HTML snippets to be previewed in Editor.js.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/manage/snippets")
|
||||
public class SnippetController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SnippetController.class);
|
||||
private final ResourceLoader resourceLoader;
|
||||
|
||||
public SnippetController(ResourceLoader resourceLoader) {
|
||||
this.resourceLoader = resourceLoader;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/manage/snippets/{id} : get the raw HTML of a snippet
|
||||
*/
|
||||
@GetMapping(value = "/{id}", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> getSnippet(@PathVariable("id") String id) {
|
||||
LOG.debug("REST request to get Snippet : {}", id);
|
||||
|
||||
// Prevent path traversal attacks
|
||||
if (id == null || id.contains("..") || id.contains("/")) {
|
||||
return ResponseEntity.badRequest().body("Invalid Snippet ID");
|
||||
}
|
||||
|
||||
String path = "classpath:templates/snippets/" + id + ".html";
|
||||
Resource resource = resourceLoader.getResource(path);
|
||||
|
||||
if (!resource.exists() || !resource.isReadable()) {
|
||||
LOG.warn("Snippet not found at path: {}", path);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("<div style=\"padding: 20px; border: 1px dashed red; color: red;\">Snippet ID <strong>" + id + "</strong> not found.</div>");
|
||||
}
|
||||
|
||||
try {
|
||||
String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
return ResponseEntity.ok().body(content);
|
||||
} catch (IOException e) {
|
||||
LOG.error("Error reading snippet file: {}", path, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error reading snippet file");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* A CMS Page entity representing a static page (e.g. About Us, Contact, Working Hours).
|
||||
* Maps to the "sis_page" database table.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "sis_page")
|
||||
public class Page 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 = "title", length = 255, nullable = false)
|
||||
private String title;
|
||||
|
||||
@NotNull
|
||||
@Size(max = 255)
|
||||
@Column(name = "slug", length = 255, nullable = false, unique = true)
|
||||
private String slug;
|
||||
|
||||
@Lob
|
||||
@Column(name = "content")
|
||||
private String content;
|
||||
|
||||
@Size(max = 500)
|
||||
@Column(name = "meta_description", length = 500)
|
||||
private String metaDescription;
|
||||
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", length = 20, nullable = false)
|
||||
private PageStatus status = PageStatus.DRAFT;
|
||||
|
||||
@Column(name = "display_order")
|
||||
private Integer displayOrder = 0;
|
||||
|
||||
// --- Getters and Setters ---
|
||||
|
||||
@Override
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getSlug() {
|
||||
return slug;
|
||||
}
|
||||
|
||||
public void setSlug(String slug) {
|
||||
this.slug = slug;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public String getMetaDescription() {
|
||||
return metaDescription;
|
||||
}
|
||||
|
||||
public void setMetaDescription(String metaDescription) {
|
||||
this.metaDescription = metaDescription;
|
||||
}
|
||||
|
||||
public PageStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(PageStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Integer getDisplayOrder() {
|
||||
return displayOrder;
|
||||
}
|
||||
|
||||
public void setDisplayOrder(Integer displayOrder) {
|
||||
this.displayOrder = displayOrder;
|
||||
}
|
||||
|
||||
// --- equals, hashCode, toString ---
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof Page page)) return false;
|
||||
return id != null && id.equals(page.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
// Use a constant value to ensure consistency across entity state transitions.
|
||||
// See: https://vladmihalcea.com/how-to-implement-equals-and-hashcode-using-the-jpa-entity-identifier/
|
||||
return getClass().hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Page{" +
|
||||
"id=" + getId() +
|
||||
", title='" + getTitle() + "'" +
|
||||
", slug='" + getSlug() + "'" +
|
||||
", status='" + getStatus() + "'" +
|
||||
", displayOrder=" + getDisplayOrder() +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.sisvietnamvn.web.domain;
|
||||
|
||||
/**
|
||||
* Enumeration representing the publication status of a CMS page.
|
||||
*/
|
||||
public enum PageStatus {
|
||||
DRAFT,
|
||||
PUBLISHED,
|
||||
ARCHIVED
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.sisvietnamvn.web.hook;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A central manager for WordPress-style Actions and Filters.
|
||||
*/
|
||||
@Service
|
||||
public class HookManager {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(HookManager.class);
|
||||
|
||||
private final Map<String, List<ActionRegistration>> actions = new HashMap<>();
|
||||
private final Map<String, List<FilterRegistration>> filters = new HashMap<>();
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
public void addAction(String hookName, ActionCallback callback, int priority) {
|
||||
actions.computeIfAbsent(hookName, k -> new ArrayList<>())
|
||||
.add(new ActionRegistration(callback, priority));
|
||||
actions.get(hookName).sort(Comparator.comparingInt(a -> a.priority));
|
||||
LOG.debug("Added action to hook '{}' with priority {}", hookName, priority);
|
||||
}
|
||||
|
||||
public void addAction(String hookName, ActionCallback callback) {
|
||||
addAction(hookName, callback, 10);
|
||||
}
|
||||
|
||||
public void doAction(String hookName, Object... args) {
|
||||
if (actions.containsKey(hookName)) {
|
||||
LOG.debug("Executing action hook '{}'", hookName);
|
||||
for (ActionRegistration reg : actions.get(hookName)) {
|
||||
try {
|
||||
reg.callback.execute(args);
|
||||
} catch (Exception e) {
|
||||
LOG.error("Error executing action hook '{}'", hookName, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Filters ---
|
||||
|
||||
public <T> void addFilter(String hookName, FilterCallback<T> callback, int priority) {
|
||||
filters.computeIfAbsent(hookName, k -> new ArrayList<>())
|
||||
.add(new FilterRegistration(callback, priority));
|
||||
filters.get(hookName).sort(Comparator.comparingInt(a -> a.priority));
|
||||
LOG.debug("Added filter to hook '{}' with priority {}", hookName, priority);
|
||||
}
|
||||
|
||||
public <T> void addFilter(String hookName, FilterCallback<T> callback) {
|
||||
addFilter(hookName, callback, 10);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> T applyFilters(String hookName, T value, Object... args) {
|
||||
if (filters.containsKey(hookName)) {
|
||||
LOG.debug("Applying filter hook '{}'", hookName);
|
||||
for (FilterRegistration reg : filters.get(hookName)) {
|
||||
try {
|
||||
value = (T) ((FilterCallback<Object>) reg.callback).filter(value, args);
|
||||
} catch (Exception e) {
|
||||
LOG.error("Error applying filter hook '{}'", hookName, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ActionCallback {
|
||||
void execute(Object... args);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface FilterCallback<T> {
|
||||
T filter(T value, Object... args);
|
||||
}
|
||||
|
||||
// --- Internal Registration Records ---
|
||||
|
||||
private static class ActionRegistration {
|
||||
final ActionCallback callback;
|
||||
final int priority;
|
||||
|
||||
ActionRegistration(ActionCallback callback, int priority) {
|
||||
this.callback = callback;
|
||||
this.priority = priority;
|
||||
}
|
||||
}
|
||||
|
||||
private static class FilterRegistration {
|
||||
final FilterCallback<?> callback;
|
||||
final int priority;
|
||||
|
||||
FilterRegistration(FilterCallback<?> callback, int priority) {
|
||||
this.callback = callback;
|
||||
this.priority = priority;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.sisvietnamvn.web.hook;
|
||||
|
||||
import com.sisvietnamvn.web.domain.Page;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* A sample plugin to demonstrate the WordPress-style Hook system in action.
|
||||
*/
|
||||
@Component
|
||||
public class SamplePluginHooks {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SamplePluginHooks.class);
|
||||
|
||||
private final HookManager hookManager;
|
||||
|
||||
public SamplePluginHooks(HookManager hookManager) {
|
||||
this.hookManager = hookManager;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void registerHooks() {
|
||||
LOG.info("Registering SamplePlugin hooks...");
|
||||
|
||||
// Example Action: Log when a page is saved
|
||||
hookManager.addAction("save_post", args -> {
|
||||
if (args.length > 0 && args[0] instanceof Page page) {
|
||||
LOG.info("ACTION TRIGGERED (save_post): Page '{}' (ID: {}) was just saved!", page.getTitle(), page.getId());
|
||||
}
|
||||
});
|
||||
|
||||
// Example Action: Log on admin init
|
||||
hookManager.addAction("admin_init", args -> {
|
||||
LOG.debug("ACTION TRIGGERED (admin_init): Admin panel accessed.");
|
||||
});
|
||||
|
||||
// Example Filter: Automatically modify page slug if it contains 'test'
|
||||
hookManager.addFilter("page_slug", (value, args) -> {
|
||||
if (value instanceof String slug) {
|
||||
if (slug.contains("test")) {
|
||||
String newSlug = slug + "-modified-by-plugin";
|
||||
LOG.info("FILTER TRIGGERED (page_slug): Changed slug from '{}' to '{}'", slug, newSlug);
|
||||
return newSlug;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}, 10);
|
||||
|
||||
// Example Filter: Pre-save filter to modify page content
|
||||
hookManager.addFilter("pre_save_post", (value, args) -> {
|
||||
if (value instanceof Page page) {
|
||||
if (page.getContent() != null && !page.getContent().contains("Auto-signature")) {
|
||||
LOG.info("FILTER TRIGGERED (pre_save_post): Appending signature to page '{}'", page.getTitle());
|
||||
// In a real scenario, this would modify the JSON content of Editor.js,
|
||||
// but for demonstration we just log it.
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}, 20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.sisvietnamvn.web.repository;
|
||||
|
||||
import com.sisvietnamvn.web.domain.Page;
|
||||
import com.sisvietnamvn.web.domain.PageStatus;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* Spring Data JPA repository for the {@link Page} entity.
|
||||
*/
|
||||
@Repository
|
||||
public interface PageRepository extends JpaRepository<Page, Long> {
|
||||
|
||||
/**
|
||||
* Find all pages ordered by display_order ascending (for admin list view).
|
||||
*/
|
||||
List<Page> findAllByOrderByDisplayOrderAsc();
|
||||
|
||||
/**
|
||||
* Find a page by its URL-friendly slug (for public page rendering).
|
||||
*/
|
||||
Optional<Page> findBySlug(String slug);
|
||||
|
||||
/**
|
||||
* Find all pages with a given publication status.
|
||||
*/
|
||||
List<Page> findByStatusOrderByDisplayOrderAsc(PageStatus status);
|
||||
|
||||
/**
|
||||
* Check if a slug already exists (for uniqueness validation).
|
||||
*/
|
||||
boolean existsBySlug(String slug);
|
||||
|
||||
/**
|
||||
* Check if a slug already exists for a different page (for edit validation).
|
||||
*/
|
||||
boolean existsBySlugAndIdNot(String slug, Long id);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.sisvietnamvn.web.service;
|
||||
|
||||
import com.sisvietnamvn.web.domain.Page;
|
||||
import com.sisvietnamvn.web.domain.PageStatus;
|
||||
import com.sisvietnamvn.web.hook.HookManager;
|
||||
import com.sisvietnamvn.web.repository.PageRepository;
|
||||
import java.text.Normalizer;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Service class for managing CMS pages.
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
public class PageService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(PageService.class);
|
||||
|
||||
private static final Pattern NON_LATIN = Pattern.compile("[^\\w-]");
|
||||
private static final Pattern WHITESPACE = Pattern.compile("[\\s]");
|
||||
|
||||
private final PageRepository pageRepository;
|
||||
private final HookManager hookManager;
|
||||
|
||||
public PageService(PageRepository pageRepository, HookManager hookManager) {
|
||||
this.pageRepository = pageRepository;
|
||||
this.hookManager = hookManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pages sorted by display order.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<Page> findAll() {
|
||||
LOG.debug("Request to get all Pages");
|
||||
return pageRepository.findAllByOrderByDisplayOrderAsc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single page by ID.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Page> findById(Long id) {
|
||||
LOG.debug("Request to get Page : {}", id);
|
||||
return pageRepository.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single page by its URL slug.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Page> findBySlug(String slug) {
|
||||
LOG.debug("Request to get Page by slug : {}", slug);
|
||||
return pageRepository.findBySlug(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all pages with a specific status.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<Page> findByStatus(PageStatus status) {
|
||||
LOG.debug("Request to get Pages by status : {}", status);
|
||||
return pageRepository.findByStatusOrderByDisplayOrderAsc(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a page (create or update).
|
||||
* Auto-generates a URL slug from the title if the slug is empty.
|
||||
*/
|
||||
public Page save(Page page) {
|
||||
LOG.debug("Request to save Page : {}", page);
|
||||
|
||||
// Hook: pre_save_post filter
|
||||
page = hookManager.applyFilters("pre_save_post", page);
|
||||
|
||||
if (page.getSlug() == null || page.getSlug().isBlank()) {
|
||||
page.setSlug(generateSlug(page.getTitle()));
|
||||
}
|
||||
// Ensure slug uniqueness by appending a suffix if needed
|
||||
String baseSlug = page.getSlug();
|
||||
String candidateSlug = baseSlug;
|
||||
int counter = 1;
|
||||
while (isSlugTaken(candidateSlug, page.getId())) {
|
||||
candidateSlug = baseSlug + "-" + counter;
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Hook: page_slug filter
|
||||
candidateSlug = hookManager.applyFilters("page_slug", candidateSlug);
|
||||
page.setSlug(candidateSlug);
|
||||
|
||||
Page savedPage = pageRepository.save(page);
|
||||
|
||||
// Hook: save_post action
|
||||
hookManager.doAction("save_post", savedPage);
|
||||
|
||||
return savedPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a page by ID.
|
||||
*/
|
||||
public void delete(Long id) {
|
||||
LOG.debug("Request to delete Page : {}", id);
|
||||
pageRepository.deleteById(id);
|
||||
|
||||
// Hook: deleted_post action
|
||||
hookManager.doAction("deleted_post", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a slug is already taken by another page.
|
||||
*/
|
||||
private boolean isSlugTaken(String slug, Long currentPageId) {
|
||||
if (currentPageId == null) {
|
||||
return pageRepository.existsBySlug(slug);
|
||||
}
|
||||
return pageRepository.existsBySlugAndIdNot(slug, currentPageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL-friendly slug from a title string.
|
||||
* Handles Vietnamese diacritics by normalizing Unicode characters.
|
||||
* Example: "Giới thiệu Bệnh viện" -> "gioi-thieu-benh-vien"
|
||||
*/
|
||||
public static String generateSlug(String input) {
|
||||
if (input == null || input.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
// Normalize Unicode (NFD) to separate base characters from diacritical marks
|
||||
String noWhitespace = WHITESPACE.matcher(input.trim()).replaceAll("-");
|
||||
String normalized = Normalizer.normalize(noWhitespace, Normalizer.Form.NFD);
|
||||
// Remove diacritical marks (combining characters)
|
||||
String noDiacritics = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
|
||||
// Handle Vietnamese special characters that NFD doesn't fully decompose
|
||||
noDiacritics = noDiacritics.replace('đ', 'd').replace('Đ', 'D');
|
||||
// Remove non-latin characters
|
||||
String slug = NON_LATIN.matcher(noDiacritics).replaceAll("");
|
||||
// Collapse multiple hyphens
|
||||
slug = slug.replaceAll("-{2,}", "-");
|
||||
// Remove leading/trailing hyphens
|
||||
slug = slug.replaceAll("^-|-$", "");
|
||||
return slug.toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ jhipster:
|
||||
# Token is valid 24 hours
|
||||
token-validity-in-seconds: 86400
|
||||
token-validity-in-seconds-for-remember-me: 2592000
|
||||
base64-secret: NGE0ZDlkMmI4YTMzMTNjOGFiZWZiMGMwNmVkNTcxNDZhZmVmMThmMDViNGMzNWI3MGVjY2YyZWJmOTMzOGI2NTIzMDdhMDVlNzU0YWJkYjcxOGM3Y2IwODcyZmMyZjg1MDU5ZjViMDIzMDJmMjc3ZmYxMWMyZDdiZWNhMmVjNGE=
|
||||
mail: # specific JHipster mail property, for standard properties see MailProperties
|
||||
base-url: http://127.0.0.1:8080
|
||||
logging:
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?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="20260625100000-1" author="sisvietnam">
|
||||
<createTable tableName="sis_page">
|
||||
<column name="id" type="bigint">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="title" type="varchar(255)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="slug" type="varchar(255)">
|
||||
<constraints nullable="false" unique="true" uniqueConstraintName="ux_sis_page_slug"/>
|
||||
</column>
|
||||
<column name="content" type="${clobType}"/>
|
||||
<column name="meta_description" type="varchar(500)"/>
|
||||
<column name="status" type="varchar(20)" defaultValue="DRAFT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="display_order" type="integer" defaultValueNumeric="0"/>
|
||||
<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>
|
||||
@@ -19,6 +19,7 @@
|
||||
<property name="timeType" value="time" dbms="oracle"/>
|
||||
|
||||
<include file="config/liquibase/changelog/00000000000000_initial_schema.xml" relativeToChangelogFile="false"/>
|
||||
<include file="config/liquibase/changelog/20260625100000_add_page_entity.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 -->
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
/* Custom CSS - This file has the highest priority and will override other styles */
|
||||
|
||||
/* ==========================================================================
|
||||
Design System Tokens (Project-wide CSS Variables)
|
||||
========================================================================== */
|
||||
:root {
|
||||
/* Primary brand colors */
|
||||
--color-primary: #007bff;
|
||||
--color-primary-hover: #0056b3;
|
||||
--color-primary-light: #f0f9ff;
|
||||
|
||||
/* Secondary & Accent colors */
|
||||
--color-secondary: #6c757d;
|
||||
--color-success: #28a745;
|
||||
--color-warning: #ffc107;
|
||||
--color-danger: #dc3545;
|
||||
|
||||
/* Neutrals */
|
||||
--color-background: #ffffff;
|
||||
--color-surface: #fafafa;
|
||||
--color-border: #dee2e6;
|
||||
|
||||
/* Text colors */
|
||||
--color-text-main: #333333;
|
||||
--color-text-muted: #6c757d;
|
||||
--color-text-light: #ffffff;
|
||||
|
||||
/* Typography */
|
||||
--font-family-base: 'Open Sans', 'Inter', sans-serif;
|
||||
--font-family-heading: 'Lora', 'Georgia', serif;
|
||||
|
||||
/* Spacing & Radii */
|
||||
--spacing-md: 20px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 8px;
|
||||
}
|
||||
|
||||
[data-component-id="umass_base:tophat"] {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* SIS Vietnam - Block Editor Configuration
|
||||
*
|
||||
* Built on Editor.js (https://editorjs.io)
|
||||
*
|
||||
* PLUGIN ARCHITECTURE:
|
||||
* =====================
|
||||
* This editor uses a plugin registry pattern. The built-in block types
|
||||
* (Header, List, Quote, etc.) are registered by default.
|
||||
*
|
||||
* To add a CUSTOM BLOCK TYPE in the future:
|
||||
*
|
||||
* 1. Create a new JS file in /js/manage/editor-plugins/
|
||||
* Example: /js/manage/editor-plugins/my-custom-block.js
|
||||
*
|
||||
* 2. In that file, register your tool BEFORE the editor initializes:
|
||||
*
|
||||
* window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
* window.SISEditorPlugins['myBlock'] = {
|
||||
* class: MyBlockClass, // Your Editor.js Tool class
|
||||
* inlineToolbar: true, // optional
|
||||
* config: { ... } // optional tool-specific config
|
||||
* };
|
||||
*
|
||||
* 3. Load the script in your template BEFORE editor-config.js:
|
||||
* <script src="/js/manage/editor-plugins/my-custom-block.js"></script>
|
||||
*
|
||||
* 4. The editor will automatically pick up all registered plugins.
|
||||
*/
|
||||
|
||||
// Global plugin registry — external plugins register here
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
|
||||
/**
|
||||
* Initialize the SIS Block Editor on a given holder element.
|
||||
*
|
||||
* @param {string} holderId - The DOM element ID for the editor container
|
||||
* @param {string} hiddenInputId - The DOM element ID for the hidden input storing JSON
|
||||
* @param {object|null} initialData - Pre-existing Editor.js JSON data to load
|
||||
* @returns {EditorJS} The editor instance
|
||||
*/
|
||||
function initSISEditor(holderId, hiddenInputId, initialData) {
|
||||
'use strict';
|
||||
|
||||
// === Built-in Tools ===
|
||||
var builtInTools = {
|
||||
header: {
|
||||
class: Header,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
placeholder: 'Enter a heading...',
|
||||
levels: [2, 3, 4],
|
||||
defaultLevel: 2
|
||||
}
|
||||
},
|
||||
list: {
|
||||
class: NestedList,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
defaultStyle: 'unordered'
|
||||
}
|
||||
},
|
||||
quote: {
|
||||
class: Quote,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
quotePlaceholder: 'Enter a quote...',
|
||||
captionPlaceholder: 'Quote author'
|
||||
}
|
||||
},
|
||||
delimiter: {
|
||||
class: Delimiter
|
||||
},
|
||||
table: {
|
||||
class: Table,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
rows: 2,
|
||||
cols: 3
|
||||
}
|
||||
},
|
||||
code: {
|
||||
class: CodeTool
|
||||
},
|
||||
warning: {
|
||||
class: Warning,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
titlePlaceholder: 'Title',
|
||||
messagePlaceholder: 'Message'
|
||||
}
|
||||
},
|
||||
marker: {
|
||||
class: Marker
|
||||
},
|
||||
inlineCode: {
|
||||
class: InlineCode
|
||||
},
|
||||
underline: {
|
||||
class: Underline
|
||||
},
|
||||
image: {
|
||||
class: ImageTool,
|
||||
config: {
|
||||
endpoints: {
|
||||
byFile: '/api/manage/media/upload',
|
||||
},
|
||||
field: 'file',
|
||||
types: 'image/*'
|
||||
}
|
||||
},
|
||||
attaches: {
|
||||
class: AttachesTool,
|
||||
config: {
|
||||
endpoint: '/api/manage/media/upload',
|
||||
field: 'file'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// === Merge built-in tools with any registered plugins ===
|
||||
var allTools = Object.assign({}, builtInTools, window.SISEditorPlugins);
|
||||
|
||||
// === Parse initial data ===
|
||||
var editorData = null;
|
||||
if (initialData && typeof initialData === 'string') {
|
||||
try {
|
||||
editorData = JSON.parse(initialData);
|
||||
} catch (e) {
|
||||
console.warn('[SIS Editor] Could not parse initial data as JSON, starting empty.', e);
|
||||
editorData = null;
|
||||
}
|
||||
} else if (initialData && typeof initialData === 'object') {
|
||||
editorData = initialData;
|
||||
}
|
||||
|
||||
// === Create the Editor ===
|
||||
var editor = new EditorJS({
|
||||
holder: holderId,
|
||||
tools: allTools,
|
||||
data: editorData || undefined,
|
||||
placeholder: 'Click here to start writing your page content...',
|
||||
autofocus: false,
|
||||
onReady: function() {
|
||||
console.log('[SIS Editor] Ready. Tools loaded:', Object.keys(allTools));
|
||||
},
|
||||
onChange: function(api, event) {
|
||||
// Auto-save to hidden input on every change
|
||||
api.saver.save().then(function(outputData) {
|
||||
var hiddenInput = document.getElementById(hiddenInputId);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = JSON.stringify(outputData);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// === Form submission handler ===
|
||||
// Ensure the latest content is saved before form submit
|
||||
var form = document.querySelector('form');
|
||||
if (form) {
|
||||
var submitHandler = function(event) {
|
||||
event.preventDefault();
|
||||
editor.save().then(function(outputData) {
|
||||
var hiddenInput = document.getElementById(hiddenInputId);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = JSON.stringify(outputData);
|
||||
}
|
||||
// Now submit the form
|
||||
form.removeEventListener('submit', submitHandler);
|
||||
form.submit();
|
||||
}).catch(function(error) {
|
||||
console.error('[SIS Editor] Save failed:', error);
|
||||
});
|
||||
};
|
||||
form.addEventListener('submit', submitHandler);
|
||||
}
|
||||
|
||||
return editor;
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* SAMPLE PLUGIN — How to create a custom Editor.js block for SIS Vietnam
|
||||
*
|
||||
* This file demonstrates the plugin pattern. Copy this file and modify it
|
||||
* to create your own custom block types.
|
||||
*
|
||||
* STEPS:
|
||||
* 1. Copy this file and rename it (e.g., "my-video-block.js")
|
||||
* 2. Create your Tool class following the Editor.js API
|
||||
* 3. Register it in window.SISEditorPlugins
|
||||
* 4. Load it in your template <script> tag BEFORE editor-config.js
|
||||
*
|
||||
* DOCUMENTATION: https://editorjs.io/creating-a-block-tool/
|
||||
*/
|
||||
|
||||
// === Example: A simple "Alert Box" block ===
|
||||
|
||||
/*
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Define your block tool class
|
||||
class AlertBox {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Alert Box',
|
||||
icon: '<svg width="17" height="15" viewBox="0 0 336 276"><path d="M291 36l-15-26a17 17 0 0 0-30 0L15 277h306L291 36z"/></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data }) {
|
||||
this.data = data || {};
|
||||
}
|
||||
|
||||
render() {
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.style.padding = '12px';
|
||||
wrapper.style.border = '2px solid #f0ad4e';
|
||||
wrapper.style.borderRadius = '4px';
|
||||
wrapper.style.backgroundColor = '#fcf8e3';
|
||||
wrapper.contentEditable = true;
|
||||
wrapper.innerHTML = this.data.text || '';
|
||||
wrapper.addEventListener('input', function() {
|
||||
this.data.text = wrapper.innerHTML;
|
||||
}.bind(this));
|
||||
this.wrapper = wrapper;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
return {
|
||||
text: blockContent.innerHTML
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin — this is the KEY step
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['alertBox'] = {
|
||||
class: AlertBox,
|
||||
inlineToolbar: true
|
||||
};
|
||||
|
||||
})();
|
||||
*/
|
||||
|
||||
// This file is intentionally commented out.
|
||||
// Uncomment the code above to enable the Alert Box block,
|
||||
// or use it as a template for your own custom blocks.
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Custom Editor.js Block Tool for embedding predefined HTML snippets.
|
||||
*/
|
||||
class HtmlSnippetTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'HTML Snippet',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api }) {
|
||||
this.data = {
|
||||
id: data.id || ''
|
||||
};
|
||||
this.api = api;
|
||||
this.wrapper = undefined;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.wrapper = document.createElement('div');
|
||||
this.wrapper.classList.add('ce-snippet-wrapper');
|
||||
this.wrapper.style.border = '1px solid #ddd';
|
||||
this.wrapper.style.padding = '15px';
|
||||
this.wrapper.style.borderRadius = '5px';
|
||||
this.wrapper.style.background = '#fafafa';
|
||||
|
||||
if (this.data.id) {
|
||||
this._showPreview(this.data.id);
|
||||
} else {
|
||||
this._showInput();
|
||||
}
|
||||
|
||||
return this.wrapper;
|
||||
}
|
||||
|
||||
_showInput() {
|
||||
this.wrapper.innerHTML = '';
|
||||
|
||||
const title = document.createElement('h4');
|
||||
title.style.marginTop = '0';
|
||||
title.style.marginBottom = '10px';
|
||||
title.innerText = 'Insert Predefined HTML Snippet';
|
||||
|
||||
const inputContainer = document.createElement('div');
|
||||
inputContainer.style.display = 'flex';
|
||||
inputContainer.style.gap = '10px';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.classList.add('ce-input');
|
||||
input.placeholder = 'Enter Snippet ID (e.g., test_banner)';
|
||||
input.value = this.data.id;
|
||||
|
||||
const loadBtn = document.createElement('button');
|
||||
loadBtn.innerText = 'Load Preview';
|
||||
loadBtn.style.padding = '5px 15px';
|
||||
loadBtn.style.cursor = 'pointer';
|
||||
|
||||
loadBtn.addEventListener('click', () => {
|
||||
if (input.value.trim()) {
|
||||
this._showPreview(input.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
inputContainer.appendChild(input);
|
||||
inputContainer.appendChild(loadBtn);
|
||||
|
||||
this.wrapper.appendChild(title);
|
||||
this.wrapper.appendChild(inputContainer);
|
||||
}
|
||||
|
||||
_showPreview(snippetId) {
|
||||
this.wrapper.innerHTML = '<div style="color: #666;">Loading preview...</div>';
|
||||
|
||||
fetch('/api/manage/snippets/' + encodeURIComponent(snippetId))
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Snippet not found');
|
||||
return response.text();
|
||||
})
|
||||
.then(html => {
|
||||
this.data.id = snippetId;
|
||||
|
||||
this.wrapper.innerHTML = '';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.display = 'flex';
|
||||
header.style.justifyContent = 'space-between';
|
||||
header.style.alignItems = 'center';
|
||||
header.style.marginBottom = '10px';
|
||||
header.style.borderBottom = '1px solid #ddd';
|
||||
header.style.paddingBottom = '5px';
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.innerText = 'Snippet: ' + snippetId;
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.innerText = 'Edit ID';
|
||||
editBtn.style.fontSize = '12px';
|
||||
editBtn.style.cursor = 'pointer';
|
||||
editBtn.addEventListener('click', () => {
|
||||
this._showInput();
|
||||
});
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(editBtn);
|
||||
|
||||
const previewArea = document.createElement('div');
|
||||
previewArea.innerHTML = html;
|
||||
|
||||
// Prevent interactions inside preview from submitting forms or acting up
|
||||
previewArea.style.pointerEvents = 'none';
|
||||
|
||||
this.wrapper.appendChild(header);
|
||||
this.wrapper.appendChild(previewArea);
|
||||
})
|
||||
.catch(error => {
|
||||
this.wrapper.innerHTML = '<div style="color: red;">Error: ' + error.message + '</div>';
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.innerText = 'Try Again';
|
||||
backBtn.style.marginTop = '10px';
|
||||
backBtn.style.cursor = 'pointer';
|
||||
backBtn.addEventListener('click', () => this._showInput());
|
||||
this.wrapper.appendChild(backBtn);
|
||||
});
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
return {
|
||||
id: this.data.id
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin globally
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['snippet'] = {
|
||||
class: HtmlSnippetTool
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
<header id="l--main-header" th:fragment="header">
|
||||
<header id="l--main-header" th:fragment="header">
|
||||
<h1 class="visually-hidden">The University of Massachusetts Amherst</h1>
|
||||
|
||||
<style>
|
||||
@@ -1354,7 +1354,7 @@
|
||||
|
||||
</div>
|
||||
<script defer src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/lazysizes/5.3.2/lazysizes.min.js"></script>
|
||||
<script defer th:src="@{/js/lazysizes.min.js}"></script>
|
||||
<script defer th:src="@{/js/custom-umass.js}"></script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
|
||||
<!-- Custom styles for this template-->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/startbootstrap-sb-admin-2/4.1.4/css/sb-admin-2.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Project-wide CSS Variables & Custom Styles -->
|
||||
<link rel="stylesheet" th:href="@{/css/custom.css}">
|
||||
</head>
|
||||
|
||||
<body id="page-top">
|
||||
@@ -47,6 +50,30 @@
|
||||
<!-- Divider -->
|
||||
<hr class="sidebar-divider">
|
||||
|
||||
<!-- Heading -->
|
||||
<div class="sidebar-heading">
|
||||
Content
|
||||
</div>
|
||||
|
||||
<!-- Nav Item - Pages Collapse Menu -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#collapsePages"
|
||||
aria-expanded="true" aria-controls="collapsePages">
|
||||
<i class="fas fa-fw fa-file-alt"></i>
|
||||
<span>Pages</span>
|
||||
</a>
|
||||
<div id="collapsePages" class="collapse" aria-labelledby="headingPages" data-parent="#accordionSidebar">
|
||||
<div class="bg-white py-2 collapse-inner rounded">
|
||||
<h6 class="collapse-header">Page Management:</h6>
|
||||
<a class="collapse-item" th:href="@{/manage/pages}">All Pages</a>
|
||||
<a class="collapse-item" th:href="@{/manage/pages/new}">Add New</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="sidebar-divider">
|
||||
|
||||
<!-- Heading -->
|
||||
<div class="sidebar-heading">
|
||||
Interface
|
||||
@@ -184,6 +211,9 @@
|
||||
<!-- Custom scripts for all pages-->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/startbootstrap-sb-admin-2/4.1.4/js/sb-admin-2.min.js"></script>
|
||||
|
||||
<!-- Page-specific scripts injected by child templates -->
|
||||
<section layout:fragment="scripts"></section>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
<!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} ? 'Add New Page' : 'Edit Page'">Page Form</title>
|
||||
<style>
|
||||
/* Editor.js container styling */
|
||||
#editorjs {
|
||||
border: 1px solid #d1d3e2;
|
||||
border-radius: 0.35rem;
|
||||
padding: 16px 12px;
|
||||
min-height: 350px;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
#editorjs:focus-within {
|
||||
border-color: #bac8f3;
|
||||
box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25);
|
||||
}
|
||||
|
||||
/* Block tool styling overrides */
|
||||
.ce-block__content {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.ce-toolbar__content {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Make the editor look clean inside the card */
|
||||
.codex-editor__redactor {
|
||||
padding-bottom: 80px !important;
|
||||
}
|
||||
|
||||
/* Plugin badge info */
|
||||
.editor-plugin-info {
|
||||
font-size: 0.75rem;
|
||||
color: #858796;
|
||||
}
|
||||
|
||||
.editor-plugin-info .badge {
|
||||
font-weight: 400;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Full Screen Editor Mode */
|
||||
.editor-fullscreen {
|
||||
position: fixed !important;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
z-index: 9999 !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
overflow-y: auto !important;
|
||||
padding: 40px !important;
|
||||
}
|
||||
</style>
|
||||
</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" th:text="${isNew} ? 'Add New Page' : 'Edit Page'">Page Form</h1>
|
||||
<a th:href="@{/manage/pages}" 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 All Pages
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary"
|
||||
th:text="${isNew} ? 'Create a New Page' : 'Update Page Details'">Form</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="pageForm" th:action="${isNew} ? @{/manage/pages} : @{/manage/pages/{id}(id=${page.id})}"
|
||||
th:object="${page}" method="post">
|
||||
|
||||
<!-- Validation errors summary -->
|
||||
<div th:if="${#fields.hasErrors('*')}" class="alert alert-danger">
|
||||
<ul class="mb-0">
|
||||
<li th:each="err : ${#fields.errors('*')}" th:text="${err}"></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="form-group">
|
||||
<label for="pageTitle" class="font-weight-bold">Title <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="pageTitle" th:field="*{title}"
|
||||
th:classappend="${#fields.hasErrors('title')} ? 'is-invalid' : ''"
|
||||
placeholder="Enter page title (e.g. About Us, Contact)" required>
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('title')}" th:errors="*{title}"></div>
|
||||
</div>
|
||||
|
||||
<!-- Slug -->
|
||||
<div class="form-group">
|
||||
<label for="pageSlug" class="font-weight-bold">Slug (URL)</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">/page/</span>
|
||||
</div>
|
||||
<input type="text" class="form-control" id="pageSlug" th:field="*{slug}"
|
||||
th:classappend="${#fields.hasErrors('slug')} ? 'is-invalid' : ''"
|
||||
placeholder="auto-generated-from-title">
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('slug')}" th:errors="*{slug}">
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted">Leave blank to auto-generate from the title. Use lowercase
|
||||
letters, numbers, and hyphens only.</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Status -->
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="pageStatus" class="font-weight-bold">Status <span
|
||||
class="text-danger">*</span></label>
|
||||
<select class="form-control" id="pageStatus" th:field="*{status}">
|
||||
<option th:each="s : ${statuses}" th:value="${s}" th:text="${s}"></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display Order -->
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="pageDisplayOrder" class="font-weight-bold">Display Order</label>
|
||||
<input type="number" class="form-control" id="pageDisplayOrder"
|
||||
th:field="*{displayOrder}" placeholder="0" min="0">
|
||||
<small class="form-text text-muted">Lower numbers appear first.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta Description -->
|
||||
<div class="form-group">
|
||||
<label for="pageMetaDescription" class="font-weight-bold">Meta Description (SEO)</label>
|
||||
<textarea class="form-control" id="pageMetaDescription" th:field="*{metaDescription}" rows="2"
|
||||
maxlength="500"
|
||||
placeholder="Brief description for search engines (max 500 characters)"></textarea>
|
||||
<small class="form-text text-muted">This appears in Google search results below the page
|
||||
title.</small>
|
||||
</div>
|
||||
|
||||
<!-- Block Editor Content -->
|
||||
<div class="form-group">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="font-weight-bold mb-0">
|
||||
<i class="fas fa-cubes"></i> Page Content (Block Editor)
|
||||
</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="toggleFullscreenBtn">
|
||||
<i class="fas fa-expand"></i> Full Screen Mode
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-plugin-info mb-2">
|
||||
Active blocks:
|
||||
<span class="badge badge-light">Heading</span>
|
||||
<span class="badge badge-light">List</span>
|
||||
<span class="badge badge-light">Quote</span>
|
||||
<span class="badge badge-light">Table</span>
|
||||
<span class="badge badge-light">Code</span>
|
||||
<span class="badge badge-light">Delimiter</span>
|
||||
<span class="badge badge-light">Warning</span>
|
||||
<span id="pluginBadges"></span>
|
||||
</div>
|
||||
|
||||
<!-- The Editor.js container -->
|
||||
<div id="editorjs"></div>
|
||||
|
||||
<!-- Hidden input to store the JSON content for form submission -->
|
||||
<input type="hidden" id="editorContent" name="content" th:value="*{content}">
|
||||
|
||||
<small class="form-text text-muted mt-2">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Click the <strong>+</strong> button or press <kbd>Tab</kbd> to add new blocks.
|
||||
Use the block menu (☰) to change block types or reorder.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a th:href="@{/manage/pages}" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span th:text="${isNew} ? 'Create Page' : 'Update Page'">Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor.js Scripts (injected via layout:fragment="scripts") -->
|
||||
<section layout:fragment="scripts">
|
||||
<!-- Editor.js Core -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.30.8/dist/editorjs.umd.js"></script>
|
||||
|
||||
<!-- Built-in Block Tools -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/header@2.8.8/dist/header.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/nested-list@1.4.3/dist/nested-list.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/quote@2.7.4/dist/quote.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/delimiter@1.4.2/dist/delimiter.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/table@2.4.2/dist/table.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/code@2.9.3/dist/code.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/warning@1.4.1/dist/warning.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/marker@1.4.0/dist/marker.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/inline-code@1.5.1/dist/inline-code.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/underline@1.1.0/dist/bundle.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/image@2.9.0/dist/image.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@editorjs/attaches@1.3.0/dist/bundle.js"></script>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
FUTURE PLUGINS: Load your custom block plugin scripts HERE.
|
||||
They must be loaded BEFORE editor-config.js so they can
|
||||
register into window.SISEditorPlugins.
|
||||
|
||||
Example:
|
||||
<script th:src="@{/js/manage/editor-plugins/my-video-block.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/my-gallery-block.js}"></script>
|
||||
============================================================
|
||||
-->
|
||||
|
||||
<!-- Custom SIS Editor Plugins -->
|
||||
<script th:src="@{/js/manage/editor-plugins/html-snippet.js}"></script>
|
||||
|
||||
<!-- Init Editor -->
|
||||
<script th:src="@{/js/manage/editor-config.js}"></script>
|
||||
|
||||
<!-- Initialize the editor with existing content (if editing) -->
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var existingContent = document.getElementById('editorContent').value;
|
||||
var initialData = null;
|
||||
|
||||
if (existingContent && existingContent.trim() !== '') {
|
||||
try {
|
||||
initialData = JSON.parse(existingContent);
|
||||
} catch (e) {
|
||||
console.warn('[SIS Editor] Existing content is not valid JSON, starting fresh.');
|
||||
}
|
||||
}
|
||||
|
||||
window.sisEditor = initSISEditor('editorjs', 'editorContent', initialData);
|
||||
|
||||
// Show badges for any injected plugins
|
||||
var pluginBadges = document.getElementById('pluginBadges');
|
||||
var pluginKeys = Object.keys(window.SISEditorPlugins || {});
|
||||
if (pluginBadges && pluginKeys.length > 0) {
|
||||
pluginKeys.forEach(function (key) {
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge badge-info ml-1';
|
||||
badge.textContent = key + ' (plugin)';
|
||||
pluginBadges.appendChild(badge);
|
||||
});
|
||||
}
|
||||
// Warn user before leaving page if they have unsaved changes
|
||||
var isFormSubmitted = false;
|
||||
document.getElementById('pageForm').addEventListener('submit', function () {
|
||||
isFormSubmitted = true;
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
if (!isFormSubmitted) {
|
||||
var confirmationMessage = 'You may have unsaved changes. Are you sure you want to leave?';
|
||||
e.returnValue = confirmationMessage;
|
||||
return confirmationMessage;
|
||||
}
|
||||
});
|
||||
|
||||
// Full Screen Mode Toggle
|
||||
var editorContainer = document.getElementById('editorjs');
|
||||
var fsBtn = document.getElementById('toggleFullscreenBtn');
|
||||
var fsIcon = fsBtn.querySelector('i');
|
||||
|
||||
fsBtn.addEventListener('click', function () {
|
||||
editorContainer.classList.toggle('editor-fullscreen');
|
||||
if (editorContainer.classList.contains('editor-fullscreen')) {
|
||||
fsIcon.classList.remove('fa-expand');
|
||||
fsIcon.classList.add('fa-compress');
|
||||
fsBtn.style.position = 'fixed';
|
||||
fsBtn.style.top = '10px';
|
||||
fsBtn.style.right = '20px';
|
||||
fsBtn.style.zIndex = '10000';
|
||||
} else {
|
||||
fsIcon.classList.remove('fa-compress');
|
||||
fsIcon.classList.add('fa-expand');
|
||||
fsBtn.style.position = 'static';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
<!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>All Pages</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">All Pages</h1>
|
||||
<a th:href="@{/manage/pages/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 Page
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<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>
|
||||
|
||||
<!-- Error Message -->
|
||||
<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>
|
||||
|
||||
<!-- Pages Table -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Manage Static Pages</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered" id="pagesTable" width="100%" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="5%">#</th>
|
||||
<th width="25%">Title</th>
|
||||
<th width="20%">Slug</th>
|
||||
<th width="10%">Status</th>
|
||||
<th width="8%">Order</th>
|
||||
<th width="17%">Last Modified</th>
|
||||
<th width="15%">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="page, iterStat : ${pages}">
|
||||
<td th:text="${iterStat.count}"></td>
|
||||
<td th:text="${page.title}"></td>
|
||||
<td>
|
||||
<code th:text="${page.slug}"></code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge"
|
||||
th:classappend="${page.status.name() == 'PUBLISHED'} ? 'badge-success' : (${page.status.name() == 'DRAFT'} ? 'badge-warning' : 'badge-secondary')"
|
||||
th:text="${page.status}">
|
||||
</span>
|
||||
</td>
|
||||
<td th:text="${page.displayOrder}"></td>
|
||||
<td th:text="${page.lastModifiedDate != null} ? ${#temporals.format(page.lastModifiedDate, 'yyyy-MM-dd HH:mm')} : '-'"></td>
|
||||
<td>
|
||||
<a th:href="@{/manage/pages/{id}/edit(id=${page.id})}" class="btn btn-sm btn-info" title="Edit">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
<form th:action="@{/manage/pages/{id}/delete(id=${page.id})}" method="post" style="display:inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Delete">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(pages)}">
|
||||
<td colspan="7" class="text-center text-muted py-4">
|
||||
<i class="fas fa-file-alt fa-2x mb-2 d-block"></i>
|
||||
No pages found. Click "Add New Page" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/layout}">
|
||||
|
||||
<head>
|
||||
<title th:text="${page.title}">Page Title</title>
|
||||
<!-- Add Meta Description for SEO -->
|
||||
<meta name="description" th:if="${page.metaDescription != null}" th:content="${page.metaDescription}" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!--
|
||||
Because the user wants absolute freedom to design the page using snippets,
|
||||
we will render the blocks directly into the content fragment with NO constraining containers.
|
||||
If they want a container, they can use a standard snippet.
|
||||
-->
|
||||
<div layout:fragment="content">
|
||||
|
||||
<!-- Render Editor.js Blocks -->
|
||||
<th:block th:each="block : ${blocks}">
|
||||
|
||||
<!-- 1. HTML Snippet Block (Custom) -->
|
||||
<th:block th:if="${block.type == 'snippet'}">
|
||||
<div th:insert="~{'snippets/' + ${block.data.id}}"></div>
|
||||
</th:block>
|
||||
|
||||
<!-- 2. Header Block -->
|
||||
<th:block th:if="${block.type == 'header'}">
|
||||
<!-- Editor.js header block has level (1-6) and text -->
|
||||
<div class="container my-3">
|
||||
<th:block th:switch="${block.data.level}">
|
||||
<h1 th:case="1" th:utext="${block.data.text}"></h1>
|
||||
<h2 th:case="2" th:utext="${block.data.text}"></h2>
|
||||
<h3 th:case="3" th:utext="${block.data.text}"></h3>
|
||||
<h4 th:case="4" th:utext="${block.data.text}"></h4>
|
||||
<h5 th:case="5" th:utext="${block.data.text}"></h5>
|
||||
<h6 th:case="6" th:utext="${block.data.text}"></h6>
|
||||
<h2 th:case="*" th:utext="${block.data.text}"></h2>
|
||||
</th:block>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- 3. Paragraph Block -->
|
||||
<th:block th:if="${block.type == 'paragraph'}">
|
||||
<div class="container">
|
||||
<p th:utext="${block.data.text}"></p>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- 4. List Block -->
|
||||
<th:block th:if="${block.type == 'list'}">
|
||||
<div class="container">
|
||||
<ul th:if="${block.data.style == 'unordered'}">
|
||||
<li th:each="item : ${block.data.items}" th:utext="${item}"></li>
|
||||
</ul>
|
||||
<ol th:if="${block.data.style == 'ordered'}">
|
||||
<li th:each="item : ${block.data.items}" th:utext="${item}"></li>
|
||||
</ol>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- 5. Image Block -->
|
||||
<th:block th:if="${block.type == 'image'}">
|
||||
<div class="container text-center my-4">
|
||||
<img th:src="${block.data.file.url}" class="img-fluid" th:alt="${block.data.caption}" />
|
||||
<p class="text-muted small mt-1" th:if="${block.data.caption}" th:utext="${block.data.caption}"></p>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- 6. Quote Block -->
|
||||
<th:block th:if="${block.type == 'quote'}">
|
||||
<div class="container my-4">
|
||||
<blockquote class="blockquote">
|
||||
<p class="mb-0" th:utext="${block.data.text}"></p>
|
||||
<footer class="blockquote-footer" th:if="${block.data.caption}" th:utext="${block.data.caption}"></footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- 7. Delimiter Block -->
|
||||
<th:block th:if="${block.type == 'delimiter'}">
|
||||
<div class="container text-center my-4">
|
||||
<span style="font-size: 24px; letter-spacing: 10px; color: #ccc;">***</span>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- 8. Table Block -->
|
||||
<th:block th:if="${block.type == 'table'}">
|
||||
<div class="container my-4">
|
||||
<table class="table table-bordered">
|
||||
<tbody>
|
||||
<tr th:each="row, rowStat : ${block.data.content}">
|
||||
<!-- If withHeadings is true, make first row <th> -->
|
||||
<th:block th:if="${block.data.withHeadings == true and rowStat.index == 0}">
|
||||
<th th:each="cell : ${row}" th:utext="${cell}"></th>
|
||||
</th:block>
|
||||
<th:block th:unless="${block.data.withHeadings == true and rowStat.index == 0}">
|
||||
<td th:each="cell : ${row}" th:utext="${cell}"></td>
|
||||
</th:block>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
</th:block>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
<div style="background-color: var(--color-primary-light); border-left: 4px solid var(--color-primary); padding: var(--spacing-md); margin: var(--spacing-md) 0; border-radius: var(--radius-md); font-family: var(--font-family-base);">
|
||||
<h2 style="margin-top: 0; color: var(--color-primary); font-family: var(--font-family-heading);">This is a predefined Snippet!</h2>
|
||||
<p style="margin-bottom: 0; color: var(--color-text-main);">You have successfully loaded the <strong>test_banner</strong> HTML snippet via the Editor.js custom block.</p>
|
||||
<button style="margin-top: 15px; background: var(--color-primary); color: var(--color-text-light); border: none; padding: 10px 20px; border-radius: var(--radius-md); cursor: pointer;">Action Button</button>
|
||||
</div>
|
||||
Reference in New Issue
Block a user