hoàn thành block editor của Pages
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
Dưới đây là danh sách đầy đủ toàn bộ các chức năng của trang quản trị WordPress (WordPress Admin Dashboard), được chia làm hai phần chính: Giao diện người dùng (UI/UX) và Các hàm PHP/APIs dành cho Lập trình viên để phát triển/tùy biến trang admin.
|
||||
|
||||
PHẦN 1: CÁC CHỨC NĂNG TRÊN GIAO DIỆN ADMIN (UI MENUS)
|
||||
Đây là các chức năng mặc định xuất hiện ở thanh Menu bên trái (Sidebar) của trang quản trị:
|
||||
|
||||
Menu chính Menu con Chức năng chi tiết
|
||||
1. Dashboard (Bảng điều khiển) Home Xem tổng quan trạng thái website (Site Health, số lượng bài viết/trang, bình luận gần đây, hoạt động viết nháp nhanh).
|
||||
Updates Cập nhật phiên bản WordPress Core, Themes, Plugins và các gói dịch vụ ngôn ngữ.
|
||||
2. Posts (Bài viết) All Posts Quản lý toàn bộ bài viết tin tức, blog, lọc theo ngày/chuyên mục.
|
||||
Add New Soạn thảo bài viết mới bằng Block Editor (Gutenberg) hoặc Classic Editor.
|
||||
Categories Tạo và quản lý các Chuyên mục (thư mục phân loại bài viết).
|
||||
Tags Tạo và quản lý các Thẻ (nhãn từ khóa tìm kiếm nhanh).
|
||||
3. Media (Thư viện) Library Quản lý toàn bộ hình ảnh, tài liệu (PDF, Docx), video, âm thanh đã tải lên.
|
||||
Add New Tải tệp tin mới lên máy chủ.
|
||||
4. Pages (Trang) All Pages Quản lý các trang tĩnh (ví dụ: Trang chủ, Trang liên hệ, Giới thiệu bệnh viện, Giờ làm việc...).
|
||||
Add New Tạo trang tĩnh mới.
|
||||
5. Comments (Bình luận) (Không có) Quản lý bình luận của người đọc (Phê duyệt, Trả lời, Đánh dấu Spam, Xóa tạm/Xóa vĩnh viễn).
|
||||
6. Appearance (Giao diện) Themes Cài đặt, kích hoạt hoặc xóa các giao diện hiển thị của website.
|
||||
Customize Chỉnh sửa giao diện trực quan theo thời gian thực (Header, Footer, Colors, Font, v.v.).
|
||||
Widgets Quản lý các khối nội dung ở Sidebar hoặc Footer.
|
||||
Menus Thiết kế menu điều hướng chính ở đầu trang và chân trang.
|
||||
Theme File Editor Chỉnh sửa trực tiếp file code của giao diện (chỉ dùng cho lập trình viên).
|
||||
7. Plugins (Gói mở rộng) Installed Plugins Quản lý, kích hoạt, vô hiệu hóa hoặc xóa các Plugin đang có trên hệ thống.
|
||||
Add New Tìm kiếm và cài đặt Plugin mới từ thư viện WordPress.org hoặc upload file .zip.
|
||||
Plugin File Editor Chỉnh sửa mã nguồn của các Plugin trực tiếp từ admin.
|
||||
8. Users (Thành viên) All Users Quản lý danh sách toàn bộ tài khoản đăng ký trên hệ thống.
|
||||
Add New Tạo tài khoản người dùng mới và chỉ định vai trò (Role).
|
||||
Profile Chỉnh sửa thông tin cá nhân của tài khoản đang đăng nhập (đổi pass, ngôn ngữ hiển thị).
|
||||
9. Tools (Công cụ) Available Tools Các công cụ hỗ trợ chuyển đổi dữ liệu.
|
||||
Import / Export Nhập/Xuất toàn bộ bài viết, trang, media sang file .xml để sao lưu hoặc chuyển host.
|
||||
Site Health Kiểm tra sức khỏe hệ thống (phiên bản PHP, MySQL, các lỗi bảo mật).
|
||||
Export / Erase Personal Data Xuất hoặc xóa dữ liệu cá nhân của người dùng để tuân thủ luật bảo mật (GDPR).
|
||||
10. Settings (Cài đặt) General Thiết lập tên website, câu khẩu hiệu, địa chỉ email admin, múi giờ, định dạng ngày tháng.
|
||||
Writing Cấu hình mặc định khi soạn thảo văn bản và gửi bài viết qua email.
|
||||
Reading Thiết lập trang chủ hiển thị, số lượng bài viết tối đa trên một trang blog, chặn công cụ tìm kiếm.
|
||||
Discussion Cấu hình bình luận (yêu cầu phê duyệt, chặn từ ngữ nhạy cảm).
|
||||
Media Cài đặt kích thước mặc định cho ảnh khi upload (Thumbnail, Medium, Large).
|
||||
Permalinks Định dạng cấu trúc đường dẫn tĩnh (URL) tối ưu cho SEO (ví dụ: /post-name/).
|
||||
Privacy Thiết lập trang chính sách bảo mật cho website.
|
||||
PHẦN 2: CÁC HÀM PHP & HOOKS ĐỂ PHÁT TRIỂN TRANG ADMIN (DEVELOPER API)
|
||||
Nếu bạn đang lập trình tùy biến trang admin (hoặc xây dựng CMS tương tự), WordPress cung cấp bộ API để tạo và kiểm soát trang admin sau:
|
||||
|
||||
1. Các hàm tạo Menu & Submenu trong Admin
|
||||
add_menu_page(): Thêm một Menu chính mới (cấp cao nhất) ở thanh sidebar bên trái.
|
||||
add_submenu_page(): Thêm một Menu con (Submenu) vào dưới một Menu chính bất kỳ.
|
||||
Các hàm phái sinh viết nhanh (Helper functions):
|
||||
add_options_page(): Thêm trang con vào menu Settings (Cài đặt).
|
||||
add_theme_page(): Thêm trang con vào menu Appearance (Giao diện).
|
||||
add_plugins_page(): Thêm trang con vào menu Plugins.
|
||||
add_users_page(): Thêm trang con vào menu Users (Thành viên).
|
||||
add_dashboard_page(): Thêm trang con vào menu Dashboard (Bảng điều khiển).
|
||||
add_management_page(): Thêm trang con vào menu Tools (Công cụ).
|
||||
2. Các Action Hooks quan trọng trong Admin
|
||||
admin_menu: Chạy khi cấu trúc menu admin đang được khởi tạo. Đây là nơi bắt buộc để gọi các hàm add_menu_page() và add_submenu_page().
|
||||
admin_init: Chạy khi trang admin được tải và khởi tạo. Thường dùng để đăng ký cấu hình (Settings API), kiểm tra quyền hoặc xử lý form gửi lên.
|
||||
admin_enqueue_scripts: Hook chuẩn duy nhất để tải thêm file CSS và JavaScript tùy chỉnh vào trang quản trị (tránh làm ảnh hưởng đến các plugin khác).
|
||||
admin_post_{action} & admin_post_nopriv_{action}: Xử lý các yêu cầu gửi Form (POST/GET) từ trang quản trị một cách an toàn.
|
||||
3. Các hàm kiểm tra Phân quyền & Trạng thái
|
||||
is_admin(): Kiểm tra xem trang hiện tại người dùng đang truy cập có thuộc phân hệ quản trị (backend /wp-admin/) hay không.
|
||||
current_user_can( $capability ): Kiểm tra người dùng hiện tại có đủ quyền thực hiện hành động hay không (ví dụ: current_user_can('manage_options') để kiểm tra quyền Admin).
|
||||
get_current_screen(): Lấy thông tin chi tiết về trang admin hiện tại đang hiển thị (ID, Base, Post Type).
|
||||
4. Settings API (Quản lý và lưu trữ thiết lập)
|
||||
register_setting(): Đăng ký một tùy chọn (option) mới trong cơ sở dữ liệu để lưu trữ cấu hình.
|
||||
add_settings_section(): Tạo ra một vùng chứa (section) trên trang để gom nhóm các trường cài đặt.
|
||||
add_settings_field(): Thêm một trường nhập liệu (Input, Checkbox, Select) vào vùng chứa đã tạo.
|
||||
settings_fields() & do_settings_sections(): Các hàm render giao diện form cấu hình tự động và tạo mã bảo mật Token (Nonce).
|
||||
TIP
|
||||
|
||||
Ứng dụng cho dự án Spring Boot hiện tại: Nếu bạn đang chuyển đổi hoặc xây dựng mới phân hệ Quản trị (Admin Panel) cho dự án website bệnh viện S.I.S Cần Thơ, bạn có thể ánh xạ các chức năng của WordPress như trên thành các Controller trong Spring Boot (ví dụ: @Controller @RequestMapping("/admin")), chia các chức năng quản lý thành các REST API và phân quyền truy cập thông qua Spring Security (tương đương với current_user_can trong WordPress).
|
||||
@@ -312,3 +312,57 @@ spring:
|
||||
username: sisvietnam
|
||||
password: sisvietnam
|
||||
```
|
||||
|
||||
## Admin Pages & Block Editor Management
|
||||
|
||||
We have implemented a WordPress-like static pages management feature at `/manage/pages`. This includes an interactive block-based editor powered by **Editor.js**.
|
||||
|
||||
### 1. How to Run and Access
|
||||
1. Start your Oracle database or ensure it is accessible.
|
||||
2. Run the application in development mode:
|
||||
```powershell
|
||||
./gradlew bootRun
|
||||
```
|
||||
3. Open your browser and navigate to: [http://localhost:8080/manage/pages](http://localhost:8080/manage/pages)
|
||||
|
||||
### 2. Form Fields & Features
|
||||
When creating or editing a page (`/manage/pages/new` or `/manage/pages/{id}/edit`):
|
||||
- **Title**: Name of the page (e.g. `About Us`, `Giới thiệu`).
|
||||
- **Slug**: The URL path. If left blank, it is automatically generated from the Title, supporting clean Vietnamese character conversions (e.g. `Bệnh viện S.I.S Cần Thơ` -> `benh-vien-s-i-s-can-tho`).
|
||||
- **Status**: Status of the page (`DRAFT`, `PUBLISHED`, `ARCHIVED`).
|
||||
- **Display Order**: Controls sorting of page links (lower numbers appear first).
|
||||
- **Meta Description**: A short summary used for SEO metadata in `<meta name="description">`.
|
||||
- **Page Content (Block Editor)**: Interactive wysiwyg editor utilizing Editor.js.
|
||||
|
||||
### 3. Using the Block Editor
|
||||
- **Add a Block**: Press `Enter` to create a new paragraph, or click the `+` icon (or press `Tab` on an empty line) to pick a block type:
|
||||
- **Heading**: Formatted HTML headers (H2, H3, H4).
|
||||
- **List**: Bulleted or numbered lists (supports nested lists via `Tab`).
|
||||
- **Quote**: Stylized blockquotes with a caption field.
|
||||
- **Table**: Simple tables with columns and rows.
|
||||
- **Code**: Formatted code block.
|
||||
- **Warning**: Notice or alert box with title & message.
|
||||
- **Delimiter**: Horizontal dividers.
|
||||
- **Inline Styling**: Highlight any text in a paragraph to apply **Bold**, *Italic*, Links, Underlines, `Inline Code`, or Highlighter.
|
||||
- **Reorder & Actions**: Hover over any block and use the handle menu (`⋮⋮`) to drag-and-drop, move up/down, convert to another block type, or delete.
|
||||
|
||||
### 4. How the Plugin System Works (Extensibility)
|
||||
If you want to create new custom block types (e.g., custom slider, doctor list, video embed) in the future:
|
||||
1. Create your plugin script file inside `src/main/resources/static/js/manage/editor-plugins/` (e.g. `video-embed-plugin.js`).
|
||||
2. Implement your custom block class or wrap an existing Editor.js tool.
|
||||
3. Register your tool globally by adding it to `window.SISEditorPlugins`:
|
||||
```javascript
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['videoEmbed'] = {
|
||||
class: VideoEmbedToolClass,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
placeholder: 'Enter video URL...'
|
||||
}
|
||||
};
|
||||
```
|
||||
4. In `src/main/resources/templates/manage/pages/form.html`, load the script in the `<section layout:fragment="scripts">` tag **before** the main configuration script:
|
||||
```html
|
||||
<script th:src="@{/js/manage/editor-plugins/video-embed-plugin.js}"></script>
|
||||
```
|
||||
5. The initialization logic in [editor-config.js](file:///c:/Users/laptop01/Documents/sisvietnamvn_01/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js) will automatically scan `window.SISEditorPlugins` and register them.
|
||||
|
||||
@@ -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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Reference in New Issue
Block a user