diff --git a/Design/adminPage_ideas.md b/Design/adminPage_ideas.md new file mode 100644 index 00000000..5a311b4b --- /dev/null +++ b/Design/adminPage_ideas.md @@ -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). \ No newline at end of file diff --git a/sisvietnamvn_main/README.md b/sisvietnamvn_main/README.md index fe19d118..e7b97b31 100644 --- a/sisvietnamvn_main/README.md +++ b/sisvietnamvn_main/README.md @@ -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 ``. +- **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 `
` tag **before** the main configuration script: + ```html + + ``` +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. diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/MvcConfig.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/MvcConfig.java new file mode 100644 index 00000000..2ebea21d --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/MvcConfig.java @@ -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 + "/"); + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/SecurityConfiguration.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/SecurityConfiguration.java index de5239cf..e98db2cf 100644 --- a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/SecurityConfiguration.java +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/SecurityConfiguration.java @@ -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() diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/WebConfigurer.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/WebConfigurer.java index b512f9e5..881e6d43 100644 --- a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/WebConfigurer.java +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/config/WebConfigurer.java @@ -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 diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/PageController.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/PageController.java new file mode 100644 index 00000000..c85faaa5 --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/PageController.java @@ -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 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> blocks = Collections.emptyList(); + if (page.getContent() != null && !page.getContent().trim().isEmpty()) { + try { + // Editor.js JSON has a "blocks" array at the root level + Map editorData = objectMapper.readValue(page.getContent(), new TypeReference<>() {}); + if (editorData.containsKey("blocks")) { + blocks = (List>) 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"; + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/ManageController.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/manage/ManageDashboardController.java similarity index 61% rename from sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/ManageController.java rename to sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/manage/ManageDashboardController.java index a1217490..a9bceb63 100644 --- a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/ManageController.java +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/manage/ManageDashboardController.java @@ -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"; } - } diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/manage/ManagePageController.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/manage/ManagePageController.java new file mode 100644 index 00000000..b4325f6a --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/manage/ManagePageController.java @@ -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 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"; + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/manage/MediaController.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/manage/MediaController.java new file mode 100644 index 00000000..77452f0d --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/manage/MediaController.java @@ -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> uploadFile(@RequestParam("file") MultipartFile file) { + log.debug("REST request to upload file: {}", file.getOriginalFilename()); + + Map 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 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); + } + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/manage/SnippetController.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/manage/SnippetController.java new file mode 100644 index 00000000..645c2481 --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/controller/manage/SnippetController.java @@ -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 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("
Snippet ID " + id + " not found.
"); + } + + 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"); + } + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/domain/Page.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/domain/Page.java new file mode 100644 index 00000000..3754258a --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/domain/Page.java @@ -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 { + + @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() + + "}"; + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/domain/PageStatus.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/domain/PageStatus.java new file mode 100644 index 00000000..1b9dfa25 --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/domain/PageStatus.java @@ -0,0 +1,10 @@ +package com.sisvietnamvn.web.domain; + +/** + * Enumeration representing the publication status of a CMS page. + */ +public enum PageStatus { + DRAFT, + PUBLISHED, + ARCHIVED +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/hook/HookManager.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/hook/HookManager.java new file mode 100644 index 00000000..47ed7d18 --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/hook/HookManager.java @@ -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> actions = new HashMap<>(); + private final Map> 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 void addFilter(String hookName, FilterCallback 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 void addFilter(String hookName, FilterCallback callback) { + addFilter(hookName, callback, 10); + } + + @SuppressWarnings("unchecked") + public 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) 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 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; + } + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/hook/SamplePluginHooks.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/hook/SamplePluginHooks.java new file mode 100644 index 00000000..f6bcc94f --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/hook/SamplePluginHooks.java @@ -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); + } +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/repository/PageRepository.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/repository/PageRepository.java new file mode 100644 index 00000000..7bc2314a --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/repository/PageRepository.java @@ -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 { + + /** + * Find all pages ordered by display_order ascending (for admin list view). + */ + List findAllByOrderByDisplayOrderAsc(); + + /** + * Find a page by its URL-friendly slug (for public page rendering). + */ + Optional findBySlug(String slug); + + /** + * Find all pages with a given publication status. + */ + List 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); +} diff --git a/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/service/PageService.java b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/service/PageService.java new file mode 100644 index 00000000..6055012e --- /dev/null +++ b/sisvietnamvn_main/src/main/java/com/sisvietnamvn/web/service/PageService.java @@ -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 findAll() { + LOG.debug("Request to get all Pages"); + return pageRepository.findAllByOrderByDisplayOrderAsc(); + } + + /** + * Get a single page by ID. + */ + @Transactional(readOnly = true) + public Optional 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 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 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); + } +} diff --git a/sisvietnamvn_main/src/main/resources/config/application-dev.yml b/sisvietnamvn_main/src/main/resources/config/application-dev.yml index decfd452..c6cad8f3 100644 --- a/sisvietnamvn_main/src/main/resources/config/application-dev.yml +++ b/sisvietnamvn_main/src/main/resources/config/application-dev.yml @@ -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: diff --git a/sisvietnamvn_main/src/main/resources/config/liquibase/changelog/20260625100000_add_page_entity.xml b/sisvietnamvn_main/src/main/resources/config/liquibase/changelog/20260625100000_add_page_entity.xml new file mode 100644 index 00000000..4937f30f --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/config/liquibase/changelog/20260625100000_add_page_entity.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sisvietnamvn_main/src/main/resources/config/liquibase/master.xml b/sisvietnamvn_main/src/main/resources/config/liquibase/master.xml index d37b767d..2f2497d0 100644 --- a/sisvietnamvn_main/src/main/resources/config/liquibase/master.xml +++ b/sisvietnamvn_main/src/main/resources/config/liquibase/master.xml @@ -19,6 +19,7 @@ + diff --git a/sisvietnamvn_main/src/main/resources/static/css/custom.css b/sisvietnamvn_main/src/main/resources/static/css/custom.css index 62b88440..1b8f1197 100644 --- a/sisvietnamvn_main/src/main/resources/static/css/custom.css +++ b/sisvietnamvn_main/src/main/resources/static/css/custom.css @@ -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; } diff --git a/sisvietnamvn_main/src/main/resources/static/js/lazysizes.min.js b/sisvietnamvn_main/src/main/resources/static/js/lazysizes.min.js new file mode 100644 index 00000000..35b726aa --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/static/js/lazysizes.min.js @@ -0,0 +1,3 @@ +/*! lazysizes - v5.3.2 */ + +!function(e){var t=function(u,D,f){"use strict";var k,H;if(function(){var e;var t={lazyClass:"lazyload",loadedClass:"lazyloaded",loadingClass:"lazyloading",preloadClass:"lazypreload",errorClass:"lazyerror",autosizesClass:"lazyautosizes",fastLoadedClass:"ls-is-cached",iframeLoadMode:0,srcAttr:"data-src",srcsetAttr:"data-srcset",sizesAttr:"data-sizes",minSize:40,customMedia:{},init:true,expFactor:1.5,hFac:.8,loadMode:2,loadHidden:true,ricTimeout:0,throttleDelay:125};H=u.lazySizesConfig||u.lazysizesConfig||{};for(e in t){if(!(e in H)){H[e]=t[e]}}}(),!D||!D.getElementsByClassName){return{init:function(){},cfg:H,noSupport:true}}var O=D.documentElement,i=u.HTMLPictureElement,P="addEventListener",$="getAttribute",q=u[P].bind(u),I=u.setTimeout,U=u.requestAnimationFrame||I,o=u.requestIdleCallback,j=/^picture$/i,r=["load","error","lazyincluded","_lazyloaded"],a={},G=Array.prototype.forEach,J=function(e,t){if(!a[t]){a[t]=new RegExp("(\\s|^)"+t+"(\\s|$)")}return a[t].test(e[$]("class")||"")&&a[t]},K=function(e,t){if(!J(e,t)){e.setAttribute("class",(e[$]("class")||"").trim()+" "+t)}},Q=function(e,t){var a;if(a=J(e,t)){e.setAttribute("class",(e[$]("class")||"").replace(a," "))}},V=function(t,a,e){var i=e?P:"removeEventListener";if(e){V(t,a)}r.forEach(function(e){t[i](e,a)})},X=function(e,t,a,i,r){var n=D.createEvent("Event");if(!a){a={}}a.instance=k;n.initEvent(t,!i,!r);n.detail=a;e.dispatchEvent(n);return n},Y=function(e,t){var a;if(!i&&(a=u.picturefill||H.pf)){if(t&&t.src&&!e[$]("srcset")){e.setAttribute("srcset",t.src)}a({reevaluate:true,elements:[e]})}else if(t&&t.src){e.src=t.src}},Z=function(e,t){return(getComputedStyle(e,null)||{})[t]},s=function(e,t,a){a=a||e.offsetWidth;while(a49?function(){o(t,{timeout:n});if(n!==H.ricTimeout){n=H.ricTimeout}}:te(function(){I(t)},true);return function(e){var t;if(e=e===true){n=33}if(a){return}a=true;t=r-(f.now()-i);if(t<0){t=0}if(e||t<9){s()}else{I(s,t)}}},ie=function(e){var t,a;var i=99;var r=function(){t=null;e()};var n=function(){var e=f.now()-a;if(e0;if(r&&Z(i,"overflow")!="visible"){a=i.getBoundingClientRect();r=C>a.left&&pa.top-1&&g500&&O.clientWidth>500?500:370:H.expand;k._defEx=u;f=u*H.expFactor;c=H.hFac;A=null;if(w2&&h>2&&!D.hidden){w=f;N=0}else if(h>1&&N>1&&M<6){w=u}else{w=_}}if(l!==n){y=innerWidth+n*c;z=innerHeight+n;s=n*-1;l=n}a=d[t].getBoundingClientRect();if((b=a.bottom)>=s&&(g=a.top)<=z&&(C=a.right)>=s*c&&(p=a.left)<=y&&(b||C||p||g)&&(H.loadHidden||x(d[t]))&&(m&&M<3&&!o&&(h<3||N<4)||W(d[t],n))){R(d[t]);r=true;if(M>9){break}}else if(!r&&m&&!i&&M<4&&N<4&&h>2&&(v[0]||H.preloadAfterLoad)&&(v[0]||!o&&(b||C||p||g||d[t][$](H.sizesAttr)!="auto"))){i=v[0]||d[t]}}if(i&&!r){R(i)}}};var a=ae(t);var S=function(e){var t=e.target;if(t._lazyCache){delete t._lazyCache;return}L(e);K(t,H.loadedClass);Q(t,H.loadingClass);V(t,B);X(t,"lazyloaded")};var i=te(S);var B=function(e){i({target:e.target})};var T=function(e,t){var a=e.getAttribute("data-load-mode")||H.iframeLoadMode;if(a==0){e.contentWindow.location.replace(t)}else if(a==1){e.src=t}};var F=function(e){var t;var a=e[$](H.srcsetAttr);if(t=H.customMedia[e[$]("data-media")||e[$]("media")]){e.setAttribute("media",t)}if(a){e.setAttribute("srcset",a)}};var s=te(function(t,e,a,i,r){var n,s,o,l,u,f;if(!(u=X(t,"lazybeforeunveil",e)).defaultPrevented){if(i){if(a){K(t,H.autosizesClass)}else{t.setAttribute("sizes",i)}}s=t[$](H.srcsetAttr);n=t[$](H.srcAttr);if(r){o=t.parentNode;l=o&&j.test(o.nodeName||"")}f=e.firesLoad||"src"in t&&(s||n||l);u={target:t};K(t,H.loadingClass);if(f){clearTimeout(c);c=I(L,2500);V(t,B,true)}if(l){G.call(o.getElementsByTagName("source"),F)}if(s){t.setAttribute("srcset",s)}else if(n&&!l){if(d.test(t.nodeName)){T(t,n)}else{t.src=n}}if(r&&(s||l)){Y(t,{src:n})}}if(t._lazyRace){delete t._lazyRace}Q(t,H.lazyClass);ee(function(){var e=t.complete&&t.naturalWidth>1;if(!f||e){if(e){K(t,H.fastLoadedClass)}S(u);t._lazyCache=true;I(function(){if("_lazyCache"in t){delete t._lazyCache}},9)}if(t.loading=="lazy"){M--}},true)});var R=function(e){if(e._lazyRace){return}var t;var a=n.test(e.nodeName);var i=a&&(e[$](H.sizesAttr)||e[$]("sizes"));var r=i=="auto";if((r||!m)&&a&&(e[$]("src")||e.srcset)&&!e.complete&&!J(e,H.errorClass)&&J(e,H.lazyClass)){return}t=X(e,"lazyunveilread").detail;if(r){re.updateElem(e,true,e.offsetWidth)}e._lazyRace=true;M++;s(e,t,r,i,a)};var r=ie(function(){H.loadMode=3;a()});var o=function(){if(H.loadMode==3){H.loadMode=2}r()};var l=function(){if(m){return}if(f.now()-e<999){I(l,999);return}m=true;H.loadMode=3;a();q("scroll",o,true)};return{_:function(){e=f.now();k.elements=D.getElementsByClassName(H.lazyClass);v=D.getElementsByClassName(H.lazyClass+" "+H.preloadClass);q("scroll",a,true);q("resize",a,true);q("pageshow",function(e){if(e.persisted){var t=D.querySelectorAll("."+H.loadingClass);if(t.length&&t.forEach){U(function(){t.forEach(function(e){if(e.complete){R(e)}})})}}});if(u.MutationObserver){new MutationObserver(a).observe(O,{childList:true,subtree:true,attributes:true})}else{O[P]("DOMNodeInserted",a,true);O[P]("DOMAttrModified",a,true);setInterval(a,999)}q("hashchange",a,true);["focus","mouseover","click","load","transitionend","animationend"].forEach(function(e){D[P](e,a,true)});if(/d$|^c/.test(D.readyState)){l()}else{q("load",l);D[P]("DOMContentLoaded",a);I(l,2e4)}if(k.elements.length){t();ee._lsFlush()}else{a()}},checkElems:a,unveil:R,_aLSL:o}}(),re=function(){var a;var n=te(function(e,t,a,i){var r,n,s;e._lazysizesWidth=i;i+="px";e.setAttribute("sizes",i);if(j.test(t.nodeName||"")){r=t.getElementsByTagName("source");for(n=0,s=r.length;n + * + * 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; +} diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/_sample-plugin.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/_sample-plugin.js new file mode 100644 index 00000000..6cddf350 --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/_sample-plugin.js @@ -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 - + + + + + +
+ +
+

Page Form

+ + Back to All Pages + +
+ + +
+
+
Form
+
+
+
+ + +
+
    +
  • +
+
+ + +
+ + +
+
+ + +
+ +
+
+ /page/ +
+ +
+
+
+ Leave blank to auto-generate from the title. Use lowercase + letters, numbers, and hyphens only. +
+ +
+ +
+
+ + +
+
+ + +
+
+ + + Lower numbers appear first. +
+
+
+ + +
+ + + This appears in Google search results below the page + title. +
+ + +
+
+ + +
+
+ Active blocks: + Heading + List + Quote + Table + Code + Delimiter + Warning + +
+ + +
+ + + + + + + Click the + button or press Tab to add new blocks. + Use the block menu (☰) to change block types or reorder. + +
+ + +
+
+ + Cancel + + +
+
+
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/sisvietnamvn_main/src/main/resources/templates/manage/pages/list.html b/sisvietnamvn_main/src/main/resources/templates/manage/pages/list.html new file mode 100644 index 00000000..227bd722 --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/templates/manage/pages/list.html @@ -0,0 +1,95 @@ + + + + + All Pages + + + + +
+ +
+

All Pages

+ + Add New Page + +
+ + + + + + + + +
+
+
Manage Static Pages
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
#TitleSlugStatusOrderLast ModifiedActions
+ + + + + + + Edit + +
+ +
+
+ + No pages found. Click "Add New Page" to create one. +
+
+
+
+
+ + + diff --git a/sisvietnamvn_main/src/main/resources/templates/page.html b/sisvietnamvn_main/src/main/resources/templates/page.html new file mode 100644 index 00000000..e9a9a431 --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/templates/page.html @@ -0,0 +1,110 @@ + + + + + Page Title + + + + + + +
+ + + + + + +
+
+ + + + +
+ +

+

+

+

+
+
+

+
+
+
+ + + +
+

+
+
+ + + +
+
    +
  • +
+
    +
  1. +
+
+
+ + + +
+ +

+
+
+ + + +
+
+

+
+
+
+
+ + + +
+ *** +
+
+ + + +
+ + + + + + + + + + + + +
+
+
+ +
+ +
+ + + diff --git a/sisvietnamvn_main/src/main/resources/templates/snippets/test_banner.html b/sisvietnamvn_main/src/main/resources/templates/snippets/test_banner.html new file mode 100644 index 00000000..e7c6c1c5 --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/templates/snippets/test_banner.html @@ -0,0 +1,5 @@ +
+

This is a predefined Snippet!

+

You have successfully loaded the test_banner HTML snippet via the Editor.js custom block.

+ +
diff --git a/sisvietnamvn_main/uploads/39379c54-ad8f-414c-b7f9-6b747290f4ab.webp b/sisvietnamvn_main/uploads/39379c54-ad8f-414c-b7f9-6b747290f4ab.webp new file mode 100644 index 00000000..465b9e98 Binary files /dev/null and b/sisvietnamvn_main/uploads/39379c54-ad8f-414c-b7f9-6b747290f4ab.webp differ diff --git a/sisvietnamvn_main/uploads/48e097c8-7cad-47aa-a8de-aaab0c7d3428.webp b/sisvietnamvn_main/uploads/48e097c8-7cad-47aa-a8de-aaab0c7d3428.webp new file mode 100644 index 00000000..465b9e98 Binary files /dev/null and b/sisvietnamvn_main/uploads/48e097c8-7cad-47aa-a8de-aaab0c7d3428.webp differ