9 Commits

98 changed files with 15416 additions and 1412 deletions
+1
View File
@@ -7,6 +7,7 @@
"request": "launch", "request": "launch",
"mainClass": "com.sisvietnamvn.web.SisvietnamvnApp", "mainClass": "com.sisvietnamvn.web.SisvietnamvnApp",
"projectName": "sisvietnamvn", "projectName": "sisvietnamvn",
"cwd": "${workspaceFolder}/sisvietnamvn_main",
"vmArgs": "-Dspring.profiles.active=dev" "vmArgs": "-Dspring.profiles.active=dev"
}, },
{ {
+69
View File
@@ -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 GISAO 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).
File diff suppressed because one or more lines are too long
@@ -0,0 +1,900 @@
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 800;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 800;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 800;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 800;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 800;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 800;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 800;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 800;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 800;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 800;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -0,0 +1 @@
/* @license GPL-2.0-or-later https://www.drupal.org/licensing/faq */
@@ -0,0 +1,11 @@
/* @license GPL-2.0-or-later https://www.drupal.org/licensing/faq */
.progress{position:relative;}.progress__track{min-width:100px;max-width:100%;height:16px;margin-top:5px;border:1px solid;background-color:#fff;}.progress__bar{width:3%;min-width:3%;max-width:100%;height:1.5em;background-color:#000;}.progress__description,.progress__percentage{overflow:hidden;margin-top:0.2em;color:#555;font-size:0.875em;}.progress__description{float:left;}[dir="rtl"] .progress__description{float:right;}.progress__percentage{float:right;}[dir="rtl"] .progress__percentage{float:left;}.progress--small .progress__track{height:7px;}.progress--small .progress__bar{height:7px;background-size:20px 20px;}
.ajax-progress{display:inline-block;padding:1px 5px 2px 5px;}[dir="rtl"] .ajax-progress{float:right;}.ajax-progress-throbber .throbber{display:inline;padding:1px 5px 2px;background:transparent url(/themes/contrib/stable/images/core/throbber-active.gif) no-repeat 0 center;}.ajax-progress-throbber .message{display:inline;padding:1px 5px 2px;}tr .ajax-progress-throbber .throbber{margin:0 2px;}.ajax-progress-bar{width:16em;}.ajax-progress-fullscreen{position:fixed;z-index:1000;top:48.5%;left:49%;width:24px;height:24px;padding:4px;opacity:0.9;border-radius:7px;background-color:#232323;background-image:url(/themes/contrib/stable/images/core/loading-small.gif);background-repeat:no-repeat;background-position:center center;}[dir="rtl"] .ajax-progress-fullscreen{right:49%;left:auto;}
.text-align-left{text-align:left;}.text-align-right{text-align:right;}.text-align-center{text-align:center;}.text-align-justify{text-align:justify;}.align-left{float:left;}.align-right{float:right;}.align-center{display:block;margin-right:auto;margin-left:auto;}
.container-inline div,.container-inline label{display:inline;}.container-inline .details-wrapper{display:block;}
.clearfix:after{display:table;clear:both;content:"";}
.hidden{display:none;}.visually-hidden{position:absolute !important;overflow:hidden;clip:rect(1px,1px,1px,1px);width:1px;height:1px;word-wrap:normal;}.visually-hidden.focusable:active,.visually-hidden.focusable:focus{position:static !important;overflow:visible;clip:auto;width:auto;height:auto;}.invisible{visibility:hidden;}
.js .js-hide{display:none;}.js-show{display:none;}.js .js-show{display:block;}
.cc_banner-wrapper{z-index:9001;position:relative}.cc_container .cc_btn{cursor:pointer;text-align:center;font-size:0.6em;transition:font-size 200ms;line-height:1em}.cc_container .cc_message{font-size:0.6em;transition:font-size 200ms;margin:0;padding:0;line-height:1.5em}.cc_container .cc_logo{display:none;text-indent:-1000px;overflow:hidden;width:100px;height:22px;background-size:cover;background-image:url(//cdnjs.cloudflare.com/ajax/libs/cookieconsent2/1.0.10/logo.png);opacity:0.9;transition:opacity 200ms}.cc_container .cc_logo:hover,.cc_container .cc_logo:active{opacity:1}@media screen and (min-width:500px){.cc_container .cc_btn{font-size:0.8em}.cc_container .cc_message{font-size:0.8em}}@media screen and (min-width:768px){.cc_container .cc_btn{font-size:1em}.cc_container .cc_message{font-size:1em;line-height:1em}}@media screen and (min-width:992px){.cc_container .cc_message{font-size:1em}}@media print{.cc_banner-wrapper,.cc_container{display:none}}.cc_container{position:fixed;left:0;right:0;bottom:0;overflow:hidden;padding:10px}.cc_container .cc_btn{padding:8px 10px;background-color:#f1d600;cursor:pointer;transition:font-size 200ms;text-align:center;font-size:0.6em;display:block;width:33%;margin-left:10px;float:right;max-width:120px}.cc_container .cc_message{transition:font-size 200ms;font-size:0.6em;display:block}@media screen and (min-width:500px){.cc_container .cc_btn{font-size:0.8em}.cc_container .cc_message{margin-top:0.5em;font-size:0.8em}}@media screen and (min-width:768px){.cc_container{padding:15px 30px 15px}.cc_container .cc_btn{font-size:1em;padding:8px 15px}.cc_container .cc_message{font-size:1em}}@media screen and (min-width:992px){.cc_container .cc_message{font-size:1em}}.cc_container{background:#222;color:#fff;font-size:17px;font-family:"Helvetica Neue Light","HelveticaNeue-Light","Helvetica Neue",Calibri,Helvetica,Arial;box-sizing:border-box}.cc_container ::-moz-selection{background:#ff5e99;color:#fff;text-shadow:none}.cc_container .cc_btn,.cc_container .cc_btn:visited{color:#000;background-color:#f1d600;transition:background 200ms ease-in-out,color 200ms ease-in-out,box-shadow 200ms ease-in-out;-webkit-transition:background 200ms ease-in-out,color 200ms ease-in-out,box-shadow 200ms ease-in-out;border-radius:5px;-webkit-border-radius:5px}.cc_container .cc_btn:hover,.cc_container .cc_btn:active{background-color:#fff;color:#000}.cc_container a,.cc_container a:visited{text-decoration:none;color:#31a8f0;transition:200ms color}.cc_container a:hover,.cc_container a:active{color:#b2f7ff}@-webkit-keyframes slideUp{0%{-webkit-transform:translateY(66px);transform:translateY(66px)}100%{-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes slideUp{0%{-webkit-transform:translateY(66px);-ms-transform:translateY(66px);transform:translateY(66px)}100%{-webkit-transform:translateY(0);-ms-transform:translateY(0);transform:translateY(0)}}.cc_container,.cc_message,.cc_btn{animation-duration:0.8s;-webkit-animation-duration:0.8s;-moz-animation-duration:0.8s;-o-animation-duration:0.8s;-webkit-animation-name:slideUp;animation-name:slideUp}
span.ext{width:10px;height:10px;padding-right:12px;text-decoration:none;background:url(/modules/contrib/extlink/images/extlink_s.png) 2px center no-repeat;}span.mailto{width:10px;height:10px;padding-right:12px;text-decoration:none;background:url(/modules/contrib/extlink/images/extlink_s.png) -20px center no-repeat;}span.tel{width:10px;height:10px;padding-right:12px;text-decoration:none;background:url(/modules/contrib/extlink/images/extlink_s.png) -42px center no-repeat;}svg.ext{width:14px;height:14px;fill:#727272;font-weight:900;}svg.mailto,svg.tel{width:14px;height:14px;fill:#727272;}[data-extlink-placement='prepend'],[data-extlink-placement='before']{padding-right:0.2rem;}[data-extlink-placement='append'],[data-extlink-placement='after']{padding-left:0.2rem;}svg.ext path,svg.mailto path,svg.tel path{stroke:#727272;stroke-width:3;}@media print{svg.ext,svg.mailto,svg.tel,span.ext,span.mailto,span.tel{display:none;padding:0;}}.extlink i{padding-left:0.2em;}.extlink-nobreak{white-space:nowrap;}
.paragraph--unpublished{background-color:#fff4f4;}
@@ -0,0 +1,43 @@
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
// Drupal's global gtag ID. For more info: https://developers.google.com/tag-platform/devguides/gtag-integration
gtag('set', 'developer_id.dMDhkMT', true);
(function (drupalSettings) {
if (!drupalSettings.gtag) {return;}
const config = drupalSettings.gtag;
if (config.consentMode === true) {
gtag('consent', 'default', {
ad_storage: 'denied',
analytics_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
wait_for_update: 500,
});
}
if (config.tagId.length !== 0) {
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${config.tagId}`;
script.type = 'text/javascript';
document.getElementsByTagName('head')[0].appendChild(script);
}
const additionalConfigInfo = config.additionalConfigInfo || [];
if (additionalConfigInfo.length === 0) {
gtag('config', config.tagId);
} else {
gtag('config', config.tagId, additionalConfigInfo);
}
const otherIds = config.otherIds || [];
otherIds.forEach((id) => gtag('config', id));
const events = config.events || [];
events.forEach((event) => gtag('event', event.name, event.data));
})(drupalSettings);
@@ -0,0 +1,31 @@
const dl = drupalSettings.gtm ? drupalSettings.gtm.settings.data_layer : 'dataLayer';
window[dl] = window[dl] || [];
(function (drupalSettings) {
if (!drupalSettings.gtm) {return;}
const config = drupalSettings.gtm;
window[dl].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
const gtmSettings = config.settings;
if (gtmSettings.include_classes === true) {
window[dl].push({
'gtm.allowlist': gtmSettings.allowlist_classes ?? [],
'gtm.blocklist': gtmSettings.blocklist_classes ?? [],
});
}
let gtm_environment = '';
if (gtmSettings.include_environment === true) {
const gtm_auth = gtmSettings.environment_token ?? '';
const gtm_preview = gtmSettings.environment_id ?? '';
gtm_environment = `&gtm_auth=${gtm_auth}&gtm_preview=${gtm_preview}&gtm_cookies_win=x`;
}
config.tagIds.forEach(function (tagId) {
const script = document.createElement('script');
script.async = true;
const dLink = dl != 'dataLayer' ? `&l=${dl}` : '';
script.src = `https://www.googletagmanager.com/gtm.js?id=${tagId}${gtm_environment}${dLink}`;
script.type = 'text/javascript';
document.getElementsByTagName('head')[0].appendChild(script);
});
})(drupalSettings);
@@ -0,0 +1,14 @@
/* @license GPL-2.0-or-later https://www.drupal.org/licensing/faq */
(function(window,$,Drupal){'use strict';window.DP=window.DP||window.Drupal||{};window.DP.behaviors=window.DP.behaviors||{};if(window.DP._behaviorBootstrapAttached)return;window.DP._behaviorBootstrapAttached=true;Drupal.behaviors.dpBehaviorBootstrap={attach:function(context){if(context!==document)return;$.each(window.DP.behaviors,function(key,behavior){if(behavior&&typeof behavior.attach==='function')behavior.attach(document);});}};}(window,jQuery,Drupal));;
(function(Drupal,$,once){'use strict';var initHoverIntent=function(){var menu=this;$('ul.m--main > li',menu).hoverIntent({over:function(){$(this).addClass('hover');},out:function(){$(this).removeClass('hover');},interval:80});};var initMainMenu=function(){var $menuItems=$('.cc--menu-main ul.m--menu > li');ally.style.focusWithin();$menuItems.on('click',function(){$menuItems.removeClass('ally-focus-within');if(whatInput.ask()==='touch')$(this).addClass('ally-focus-within');});};window.DP.behaviors.mainMenu={attach:function(context){$(once('mainMenu','.nav-container .cc--menu-main',context)).each(function(){initHoverIntent.apply(this);initMainMenu.apply();});}};})(Drupal,jQuery,once);;
(function(Drupal,$,once){'use strict';var $hamburger;var $mobileMenu;var $arrowToggles;var $backButtons;var $subMenus;var breakpoint=window.matchMedia('(min-width: 1200px)');var initMobileMenu=function(){$hamburger=$(this);$mobileMenu=$('.cc--menu-mobile');$arrowToggles=$mobileMenu.find('.arrow-toggle');$subMenus=$mobileMenu.find('.submenu-wrapper');$backButtons=$mobileMenu.find('.button-back');$hamburger.on('click',function(e){e.preventDefault();toggleMobileMenu(this);});$arrowToggles.on('click',function(e){e.preventDefault();var $subMenu=$(this).parents('.menu-item').find('.submenu-wrapper');$subMenu.attr('aria-hidden','false');$subMenu.show();$subMenu.removeClass('is-hidden').addClass('is-visible');$mobileMenu.addClass('is-hidden');});$backButtons.on('click',function(e){e.preventDefault();var $this=$(this);$this.parents('.submenu-wrapper').attr('aria-hidden','true');$this.parents('.submenu-wrapper').addClass('is-hidden');$this.parents('.submenu-wrapper').removeClass('is-visible');$mobileMenu.removeClass('is-hidden');});checkBreakpoint();breakpoint.addListener(checkBreakpoint);};var checkBreakpoint=function(){if(breakpoint.matches){$hamburger.removeClass('is-active');$mobileMenu.removeAttr('style');$('body').removeClass('mobile-menu-is-active');hideAllSubmenus();}};var toggleMobileMenu=function(el){$('body').toggleClass('mobile-menu-is-active');$(el).toggleClass('is-active');$mobileMenu.fadeToggle(function(){if(!$('body').hasClass('mobile-menu-is-active'))hideAllSubmenus();});};var hideAllSubmenus=function(){$subMenus.removeClass('is-visible');$mobileMenu.removeClass('is-hidden');};window.DP.behaviors.mobileMenu={attach:function(context){$(once('mobileMenu','.hamburger',context)).each(function(){initMobileMenu.apply(this);});}};}(Drupal,jQuery,once));;
(function($){'use strict';var $searchIcon;var $body=$('body');var $searchFormContainer=$('.cc--header .cc--header-search');var $searchInput=$searchFormContainer.find('input');var $closeIcon=$searchFormContainer.find('.button-close');var initSearchForm=function(){$searchIcon=$(this);$searchFormContainer.attr({'aria-hidden':'true'});$searchIcon.on('click',function(e){e.preventDefault();openSearch();});$closeIcon.on('click',function(e){e.preventDefault();closeSearch();});$searchFormContainer.on('keydown',function(ev){if(ev.keyCode===9){var inputs=$searchFormContainer.find('input, button').filter(':visible').not(':disabled');if(!ev.shiftKey){if(inputs[inputs.length-1]===ev.target){ev.preventDefault();inputs.first().focus();}}else{if(inputs[0]===ev.target){ev.preventDefault();inputs.last().focus();}}}if(ev.keyCode===27){ev.preventDefault();closeSearch();}});};var openSearch=function(){$searchFormContainer.addClass('is-open').attr('aria-hidden','false');$body.addClass('search-open');$searchInput.first().focus();};var closeSearch=function(){$searchFormContainer.removeClass('is-open').attr('aria-hidden','true');$body.removeClass('search-open');};window.DP.behaviors.searchForm={attach:function(context){$(once('searchForm','.utility-nav-container .search-button',context)).each(function(){initSearchForm.apply(this);});}};}(jQuery));;
/**
* what-input - A global utility for tracking the current input method (mouse, keyboard or touch).
* @version v5.2.3
* @link https://github.com/ten1seven/what-input
* @license MIT
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define("whatInput",[],t):"object"==typeof exports?exports.whatInput=t():e.whatInput=t()}(this,function(){return i={},n.m=o=[function(e,t){"use strict";e.exports=function(){if("undefined"==typeof document||"undefined"==typeof window)return{ask:function(){return"initial"},element:function(){return null},ignoreKeys:function(){},specificKeys:function(){},registerOnChange:function(){},unRegisterOnChange:function(){}};var t=document.documentElement,n=null,a="initial",u=a,o=Date.now();try{window.sessionStorage.getItem("what-input")&&(a=window.sessionStorage.getItem("what-input")),window.sessionStorage.getItem("what-intent")&&(u=window.sessionStorage.getItem("what-intent"))}catch(e){}var d=["button","input","select","textarea"],i=[],c=[16,17,18,91,93],w=[],p={keydown:"keyboard",keyup:"keyboard",mousedown:"mouse",mousemove:"mouse",MSPointerDown:"pointer",MSPointerMove:"pointer",pointerdown:"pointer",pointermove:"pointer",touchstart:"touch",touchend:"touch"},r=!1,s={x:null,y:null},f={2:"touch",3:"touch",4:"mouse"},l=!1;try{var e=Object.defineProperty({},"passive",{get:function(){l=!0}});window.addEventListener("test",null,e)}catch(e){}var h=function(){var e=!!l&&{passive:!0};window.PointerEvent?(window.addEventListener("pointerdown",m),window.addEventListener("pointermove",y)):window.MSPointerEvent?(window.addEventListener("MSPointerDown",m),window.addEventListener("MSPointerMove",y)):(window.addEventListener("mousedown",m),window.addEventListener("mousemove",y),"ontouchstart"in window&&(window.addEventListener("touchstart",m,e),window.addEventListener("touchend",m))),window.addEventListener(S(),y,e),window.addEventListener("keydown",m),window.addEventListener("keyup",m),window.addEventListener("focusin",g),window.addEventListener("focusout",E)},m=function(e){var t=e.which,n=p[e.type];"pointer"===n&&(n=L(e));var o=!w.length&&-1===c.indexOf(t),i=w.length&&-1!==w.indexOf(t),r="keyboard"===n&&t&&(o||i)||"mouse"===n||"touch"===n;if(x(n)&&(r=!1),r&&a!==n){a=n;try{window.sessionStorage.setItem("what-input",a)}catch(e){}v("input")}if(r&&u!==n){var s=document.activeElement;if(s&&s.nodeName&&(-1===d.indexOf(s.nodeName.toLowerCase())||"button"===s.nodeName.toLowerCase()&&!O(s,"form"))){u=n;try{window.sessionStorage.setItem("what-intent",u)}catch(e){}v("intent")}}},v=function(e){t.setAttribute("data-what"+e,"input"===e?a:u),b(e)},y=function(e){var t=p[e.type];if("pointer"===t&&(t=L(e)),M(e),(!r&&!x(t)||r&&"wheel"===e.type||"mousewheel"===e.type||"DOMMouseScroll"===e.type)&&u!==t){u=t;try{window.sessionStorage.setItem("what-intent",u)}catch(e){}v("intent")}},g=function(e){e.target.nodeName?(n=e.target.nodeName.toLowerCase(),t.setAttribute("data-whatelement",n),e.target.classList&&e.target.classList.length&&t.setAttribute("data-whatclasses",e.target.classList.toString().replace(" ",","))):E()},E=function(){n=null,t.removeAttribute("data-whatelement"),t.removeAttribute("data-whatclasses")},L=function(e){return"number"==typeof e.pointerType?f[e.pointerType]:"pen"===e.pointerType?"touch":e.pointerType},x=function(e){var t=Date.now(),n="mouse"===e&&"touch"===a&&t-o<200;return o=t,n},S=function(){return"onwheel"in document.createElement("div")?"wheel":void 0!==document.onmousewheel?"mousewheel":"DOMMouseScroll"},b=function(e){for(var t=0,n=i.length;t<n;t++)i[t].type===e&&i[t].fn.call(void 0,"input"===e?a:u)},M=function(e){s.x!==e.screenX||s.y!==e.screenY?(r=!1,s.x=e.screenX,s.y=e.screenY):r=!0},O=function(e,t){var n=window.Element.prototype;if(n.matches||(n.matches=n.msMatchesSelector||n.webkitMatchesSelector),n.closest)return e.closest(t);do{if(e.matches(t))return e;e=e.parentElement||e.parentNode}while(null!==e&&1===e.nodeType);return null};return"addEventListener"in window&&Array.prototype.indexOf&&(p[S()]="mouse",h(),v("input"),v("intent")),{ask:function(e){return"intent"===e?u:a},element:function(){return n},ignoreKeys:function(e){c=e},specificKeys:function(e){w=e},registerOnChange:function(e,t){i.push({fn:e,type:t||"input"})},unRegisterOnChange:function(e){var t=function(e){for(var t=0,n=i.length;t<n;t++)if(i[t].fn===e)return t}(e);!t&&0!==t||i.splice(t,1)}}}()}],n.c=i,n.p="",n(0);function n(e){if(i[e])return i[e].exports;var t=i[e]={exports:{},id:e,loaded:!1};return o[e].call(t.exports,t,t.exports,n),t.loaded=!0,t.exports}var o,i});
;
@@ -0,0 +1,2 @@
/* @license GPL-2.0-or-later https://www.drupal.org/licensing/faq */
(function(){const settingsElement=document.querySelector('head > script[type="application/json"][data-drupal-selector="drupal-settings-json"], body > script[type="application/json"][data-drupal-selector="drupal-settings-json"]');window.drupalSettings={};if(settingsElement!==null)window.drupalSettings=JSON.parse(settingsElement.textContent);})();;
@@ -0,0 +1 @@
!function(i,r){function o(){}var n,e,t,c,a,d,l,s,u,f,p,y,m,v,h,_,g,E,k,S="addtoany",b=".bycdb5qo",L=i.head;"function"==typeof[].indexOf&&L&&(r.a2a=r.a2a||{},n=r.a2a_config=r.a2a_config||{},_=(e=i.currentScript instanceof HTMLScriptElement?i.currentScript:null)&&e.src?e.src:"",t=e&&e.nonce?e.nonce:null,c=r.a2a.nonce=function(e){t&&e.setAttribute("nonce",t)},a=e&&!e.async&&!e.defer,NodeList&&NodeList.prototype.forEach&&(r.a2a.init=function(e,t){void 0===t&&(t=n);var a=":not([data-a2a-url]):not(.a2a_target)";i.querySelectorAll(".a2a_dd"+a+",.a2a_kit"+a).forEach(function(e){e.matches(".a2a_kit .a2a_dd");e.a2a_index||null!==e.getAttribute("data-a2a-url")||e.matches(".a2a_kit .a2a_dd")||(e.dataset.a2aUrl=t.linkurl||"",t.linkname&&(e.dataset.a2aTitle=t.linkname))}),delete n.linkurl,delete n.linkname},r.a2a_init=r.a2a.init,a)&&r.a2a.init("page",{linkurl:n.linkurl,linkname:n.linkname}),r.a2a.page||(r.a2a.page=!0,d=[],["init_all","svg_css"].forEach(function(a){r.a2a[a]=function(){for(var e=[],t=0;t<arguments.length;t++)e[t]=arguments[t];d.push([a,e])}}),s=(l=n.static_server)?l+"/":"https://static.addtoany.com/menu/",k=_&&-1!==_.split("/")[2].indexOf(S),u=(k=(u=!l&&k?_:s).match(/^[^?#]+\//))?k[0]:u,null!=(_=r.trustedTypes)&&_.createPolicy&&(f=r.a2a.ttp=trustedTypes.createPolicy("addtoany",{createHTML:function(e){return e},createScript:function(e){return e},createScriptURL:function(e){return e}})),p=function(e,t,a){void 0===e&&(e=u+"eso"+b+".js"),void 0===t&&(t=!1);var n=i.createElement((a=void 0===a?!1:a)?"link":"script");a?(a="preload",n.href=e,n.rel=t?"module"+a:a,t||(n.as="script")):(n.src=f?f.createScriptURL(e):e,t&&(n.type="module",n.onerror=function(){return p()})),c(n),L.appendChild(n)},y=function(){var e=u+(l?"":"modules/");p(e+"core"+b+".js",!0);var t=i.createElement("link").relList.supports("modulepreload");n.overlays&&n.overlays.length&&t&&p(e+"overlays"+b+".js",!0,!0),y=o},v="a2a_sm_ifr",h=function(){var e,t,a,n;t="a2a_menu_container",e=i.getElementById(t),r.a2a.main=m=e||i.createElement("div"),m.id!=t&&(i.getElementById(S)||(m.id=S),m.style.position="static",i.body.insertBefore(m,null)),r.addEventListener("message",function(e){var t=e.origin;t&&".addtoany.com"!==t.substr(-13)||"object"==typeof(t=e.data)&&t.a2a&&(t.h1&&(r.a2a.h1=!0),"function"==typeof(e=r.a2a.userServices)?e(t.user_services):r.a2a.userServices=t.user_services,i.getElementById(v).style.display="none")}),e=i.createElement("iframe"),t=i.createElement("div"),a=e.style,n=t.style,e.id=v,a.width=a.height=n.width=n.height="1px",a.top=a.left=a.border="0",a.position=n.position="absolute",a.zIndex=n.zIndex="100000",e.title="AddToAny Utility Frame",e.setAttribute("aria-hidden","true"),e.src="https://static.addtoany.com/menu/sm.25.html#type=core&event=load",n.top="0",n.visibility="hidden",m.insertBefore(t,null),t.insertBefore(e,null),h=o},i.body&&h(),"function"==typeof(k="".matchAll)&&k.toString().includes("[native code]")?("loading"!==i.readyState&&y(),_=s+(l?"":"svg/"),i.querySelector('.a2a_dd:empty,.a2a_kit [class*="a2a_button_"]:empty')&&r.a2a.h1&&p(_+"icons.39.svg.js",!1,!0)):p(),g=function(e){var t=r.a2a.core;"function"!=typeof t||e?e?e():r.a2a.core=function(e){return g(e)}:t()},k=function(){E||(E=!0,y(),h(),g(),d.forEach(function(e){var t;(t=r.a2a)[e[0]].apply(t,e[1])}))},!(a&&L.contains(e)||!i.body)||"loading"!==i.readyState?k():(i.addEventListener("readystatechange",k),i.addEventListener("DOMContentLoaded",k))))}(document,window);
@@ -0,0 +1,4 @@
<!DOCTYPE html>
<!-- saved from url=(0064)https://static.addtoany.com/menu/sm.25.html#type=core&event=load -->
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><title>A2A</title><meta name="robots" content="noindex"><script>
!function(e){var o=document.cookie,t=0<o.length;var n,a={feed:[],page:[]};t&&["feed","page"].forEach(function(e){var t=e+"_services",n=o.indexOf(t+"=");-1!=n&&(-1==(t=o.indexOf(";",n=n+t.length+1))&&(t=o.length),n=decodeURIComponent(o.substring(n,t)),a[e]=n.split(","))}),n={a2a:!0,h1:(t=!1,"function"==typeof performance.getEntriesByType&&(n=(n=performance.getEntriesByType("navigation")[0])&&n.nextHopProtocol?n.nextHopProtocol:"",t=/^http\/1/.test(n)),t),user_services:{feed:a.feed,page:a.page}},e.postMessage&&e.parent.postMessage(n,"*")}(window);</script></head><body style="background-color:transparent"></body></html>
+6
View File
@@ -151,3 +151,9 @@ Desktop.ini
###################### ######################
coverage/ coverage/
.nyc_output/ .nyc_output/
######################
# Database
######################
src/main/docker/oracle-data/
+58
View File
@@ -312,3 +312,61 @@ spring:
username: sisvietnam username: sisvietnam
password: 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 the Page Management dashboard: [http://localhost:8080/manage/pages](http://localhost:8080/manage/pages)
4. Navigate to the Posts Management dashboards:
- **All Posts**: [http://localhost:8080/manage/posts](http://localhost:8080/manage/posts)
- **Categories**: [http://localhost:8080/manage/categories](http://localhost:8080/manage/categories)
- **Tags**: [http://localhost:8080/manage/tags](http://localhost:8080/manage/tags)
### 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.
-1
View File
@@ -55,7 +55,6 @@ dependencies {
implementation libs.springdoc.openapi.starter.webmvc.api implementation libs.springdoc.openapi.starter.webmvc.api
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
implementation "com.oracle.database.jdbc:ojdbc8" implementation "com.oracle.database.jdbc:ojdbc8"
implementation "org.springframework.boot:spring-boot-h2console"
implementation "org.springframework.boot:spring-boot-jackson2" implementation "org.springframework.boot:spring-boot-jackson2"
implementation "org.springframework.boot:spring-boot-starter" implementation "org.springframework.boot:spring-boot-starter"
implementation "org.springframework.boot:spring-boot-starter-actuator" implementation "org.springframework.boot:spring-boot-starter-actuator"
+53
View File
@@ -0,0 +1,53 @@
# This configuration combines the app and database services for easy execution at the project root
name: sisvietnamvn
services:
oracle:
image: container-registry.oracle.com/database/free:latest
restart: always
ports:
- "1521:1521"
environment:
- ORACLE_PWD=Oracle123!
- ORACLE_PASSWORD=Oracle123!
- ORACLE_PDB=sisvietnam
- ORACLE_DATABASE=sisvietnam
- APP_USER=sisvietnam
- APP_USER_PASSWORD=sisvietnam
volumes:
- oracle_data:/opt/oracle/oradata
networks:
- sisvietnamvn-net
app:
image: git.victorphan.net/basketballcantho/sisvietnamvn:latest
depends_on:
oracle:
condition: service_healthy
environment:
- _JAVA_OPTIONS=-Xmx512m -Xms256m
- SPRING_PROFILES_ACTIVE=prod,api-docs,secret-samples
- MANAGEMENT_PROMETHEUS_METRICS_EXPORT_ENABLED=true
- SPRING_DATASOURCE_URL=jdbc:oracle:thin:@oracle:1521/FREEPDB1
- SPRING_DATASOURCE_USERNAME=SYSTEM
- SPRING_DATASOURCE_PASSWORD=Oracle123!
- SPRING_LIQUIBASE_URL=jdbc:oracle:thin:@oracle:1521/FREEPDB1
ports:
- "8080:8080"
healthcheck:
test:
[
'CMD-SHELL',
'bash -c ''exec 3<>/dev/tcp/127.0.0.1/8080; printf "GET /management/health HTTP/1.0\r\nHost: localhost\r\n\r\n" >&3; grep -q "\"status\":\"UP\"" <&3''',
]
interval: 5s
timeout: 5s
retries: 40
networks:
- sisvietnamvn-net
networks:
sisvietnamvn-net:
driver: bridge
volumes:
oracle_data:
Vendored Regular → Executable
View File
+5 -5
View File
@@ -2,15 +2,15 @@
name: sisvietnamvn name: sisvietnamvn
services: services:
app: app:
image: sisvietnamvn image: git.victorphan.net/basketballcantho/sisvietnamvn:latest
environment: environment:
- _JAVA_OPTIONS=-Xmx512m -Xms256m - _JAVA_OPTIONS=-Xmx512m -Xms256m
- SPRING_PROFILES_ACTIVE=prod,api-docs,secret-samples - SPRING_PROFILES_ACTIVE=prod,api-docs,secret-samples
- MANAGEMENT_PROMETHEUS_METRICS_EXPORT_ENABLED=true - MANAGEMENT_PROMETHEUS_METRICS_EXPORT_ENABLED=true
- SPRING_DATASOURCE_URL=jdbc:oracle:thin:@oracle:1521:sisvietnamvn - SPRING_DATASOURCE_URL=jdbc:oracle:thin:@oracle:1521/sisvietnam
- SPRING_DATASOURCE_USERNAME=system - SPRING_DATASOURCE_USERNAME=SYSTEM
- SPRING_DATASOURCE_PASSWORD=oracle - SPRING_DATASOURCE_PASSWORD=Oracle123!
- SPRING_LIQUIBASE_URL=jdbc:oracle:thin:@oracle:1521:sisvietnamvn - SPRING_LIQUIBASE_URL=jdbc:oracle:thin:@oracle:1521/sisvietnam
# If you want to expose these ports outside your dev PC, # If you want to expose these ports outside your dev PC,
# remove the "127.0.0.1:" prefix # remove the "127.0.0.1:" prefix
ports: ports:
+6 -5
View File
@@ -1,16 +1,17 @@
# This configuration is intended for development purpose, it's **your** responsibility to harden it for production # This configuration is intended for development purpose, it's **your** responsibility to harden it for production
name: sisvietnam-oracle name: sisvietnamvn
services: services:
sisvietnam-oracle: oracle:
image: container-registry.oracle.com/database/free:latest image: container-registry.oracle.com/database/free:latest
restart: always restart: always
network_mode: "host" ports:
- "1521:1521"
environment: environment:
# Password for the SYS, SYSTEM, and PDBADMIN administrative accounts (official image uses ORACLE_PWD) # Password for the SYS, SYSTEM, and PDBADMIN administrative accounts
- ORACLE_PWD=Oracle123! - ORACLE_PWD=Oracle123!
- ORACLE_PASSWORD=Oracle123! - ORACLE_PASSWORD=Oracle123!
- ORACLE_PDB=sisvietnam - ORACLE_PDB=sisvietnam
# Name of the pluggable database (official image default is FREEPDB1) # Name of the pluggable database
- ORACLE_DATABASE=sisvietnam - ORACLE_DATABASE=sisvietnam
# Note: The official Oracle Free image does not automatically create custom app users/databases. # Note: The official Oracle Free image does not automatically create custom app users/databases.
# If needed, create the 'sisvietnam' user manually or use the SYSTEM user to connect. # If needed, create the 'sisvietnam' user manually or use the SYSTEM user to connect.
@@ -14,13 +14,12 @@ import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.h2console.autoconfigure.H2ConsoleAutoConfiguration;
import org.springframework.boot.liquibase.autoconfigure.LiquibaseProperties; import org.springframework.boot.liquibase.autoconfigure.LiquibaseProperties;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import tech.jhipster.config.DefaultProfileUtil; import tech.jhipster.config.DefaultProfileUtil;
import tech.jhipster.config.JHipsterConstants; import tech.jhipster.config.JHipsterConstants;
@SpringBootApplication(exclude = { H2ConsoleAutoConfiguration.class }) @SpringBootApplication
@EnableConfigurationProperties({ LiquibaseProperties.class, ApplicationProperties.class }) @EnableConfigurationProperties({ LiquibaseProperties.class, ApplicationProperties.class })
public class SisvietnamvnApp { public class SisvietnamvnApp {
@@ -5,7 +5,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.h2console.autoconfigure.H2ConsoleProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
@@ -14,13 +13,11 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.EnableTransactionManagement;
import tech.jhipster.config.JHipsterConstants; import tech.jhipster.config.JHipsterConstants;
import tech.jhipster.config.h2.H2ConfigurationHelper;
@Configuration @Configuration
@EnableJpaRepositories({ "com.sisvietnamvn.web.repository" }) @EnableJpaRepositories({ "com.sisvietnamvn.web.repository" })
@EnableJpaAuditing(auditorAwareRef = "springSecurityAuditorAware") @EnableJpaAuditing(auditorAwareRef = "springSecurityAuditorAware")
@EnableTransactionManagement @EnableTransactionManagement
@EnableConfigurationProperties(H2ConsoleProperties.class)
public class DatabaseConfiguration { public class DatabaseConfiguration {
private static final Logger LOG = LoggerFactory.getLogger(DatabaseConfiguration.class); private static final Logger LOG = LoggerFactory.getLogger(DatabaseConfiguration.class);
@@ -30,33 +27,4 @@ public class DatabaseConfiguration {
public DatabaseConfiguration(Environment env) { public DatabaseConfiguration(Environment env) {
this.env = env; this.env = env;
} }
/**
* Open the TCP port for the H2 database, so it is available remotely.
*
* @return the H2 database TCP server.
* @throws SQLException if the server failed to start.
*/
@Bean(initMethod = "start", destroyMethod = "stop")
@Profile(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT)
@ConditionalOnProperty(prefix = "spring.h2.console", name = "enabled", havingValue = "true")
public Object h2TCPServer() throws SQLException {
String port = getValidPortForH2();
LOG.debug("H2 database is available on port {}", port);
return H2ConfigurationHelper.createServer(port);
}
private String getValidPortForH2() {
var port = Integer.parseInt(env.getProperty("server.port"));
if (port < 10000) {
port = 10000 + port;
} else {
if (port < 63536) {
port = port + 2000;
} else {
port = port - 2000;
}
}
return String.valueOf(port);
}
} }
@@ -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 + "/");
}
}
@@ -46,8 +46,10 @@ public class SecurityConfiguration {
authz authz
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he", .requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
"/manage/**", "/css/**", "/images/**", "/js/**", "/UMass*/**", "/Undergraduate*/**", "/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/**", "/news/article/**", "/post/**", "/error",
"/about-us", "/specialty", "/doctor", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us")
.permitAll() .permitAll()
.requestMatchers(HttpMethod.POST, "/manage/**", "/api/manage/media/upload").permitAll()
.requestMatchers(HttpMethod.GET, "/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers(HttpMethod.GET, "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/authenticate").permitAll() .requestMatchers(HttpMethod.POST, "/api/authenticate").permitAll()
.requestMatchers(HttpMethod.GET, "/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.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter; import org.springframework.web.filter.CorsFilter;
import com.sisvietnamvn.web.hook.HookManager;
import tech.jhipster.config.JHipsterConstants; import tech.jhipster.config.JHipsterConstants;
import tech.jhipster.config.JHipsterProperties; import tech.jhipster.config.JHipsterProperties;
import tech.jhipster.config.h2.H2ConfigurationHelper; 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 static final Logger LOG = LoggerFactory.getLogger(WebConfigurer.class);
private final Environment env; private final Environment env;
private final JHipsterProperties jHipsterProperties; 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.env = env;
this.jHipsterProperties = jHipsterProperties; this.jHipsterProperties = jHipsterProperties;
this.hookManager = hookManager;
} }
@Override @Override
@@ -44,6 +46,7 @@ public class WebConfigurer implements ServletContextInitializer {
initH2Console(servletContext); initH2Console(servletContext);
} }
LOG.info("Web application fully configured"); LOG.info("Web application fully configured");
hookManager.doAction("init");
} }
@Bean @Bean
@@ -6,8 +6,9 @@ import org.springframework.web.bind.annotation.GetMapping;
@Controller @Controller
public class HomeController { public class HomeController {
@GetMapping("/") @GetMapping("/")
public String home() { public String index() {
return "index"; return "index";
} }
@@ -20,17 +21,17 @@ public class HomeController {
// Trang Giới thiệu Bệnh viện // Trang Giới thiệu Bệnh viện
@GetMapping("/about") @GetMapping("/about")
public String about() { public String about() {
return "about"; return "redirect:/about-us";
} }
// Trang Tin tức // Trang Tin tức
@GetMapping("/tin-tuc") @GetMapping("/tin-tuc")
public String tintuc() { public String tintuc() {
return "tin-tuc"; return "redirect:/news-and-events";
} }
@GetMapping("/lien-he") @GetMapping("/lien-he")
public String lienhe() { public String lienhe() {
return "lien-he"; return "redirect:/contact-us";
} }
} }
@@ -0,0 +1,110 @@
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);
return renderPage(pageService.findBySlug(slug), model);
}
@GetMapping("/about-us")
public String getAboutUs(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.ABOUT_US), model); }
@GetMapping("/specialty")
public String getSpecialty(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SPECIALTY), model); }
@GetMapping("/doctor")
public String getDoctor(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.DOCTOR), model); }
@GetMapping("/service")
public String getService(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SERVICE), model); }
@GetMapping("/health-library")
public String getHealthLibrary(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.HEALTH_LIBRARY), model); }
@GetMapping("/news-and-events")
public String getNewsAndEvents(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.NEWS_AND_EVENTS), model); }
@GetMapping("/patient-support")
public String getPatientSupport(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.PATIENT_SUPPORT), model); }
@GetMapping("/medical-expert")
public String getMedicalExpert(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.MEDICAL_EXPERT), model); }
@GetMapping("/umcers")
public String getUmcers(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.UMCERS), model); }
@GetMapping("/bidding")
public String getBidding(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.BIDDING), model); }
@GetMapping("/contact-us")
public String getContactUs(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.CONTACT_US), model); }
private String renderPage(Optional<Page> pageOpt, Model model) {
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 {
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 ID: {}", page.getId(), e);
}
}
model.addAttribute("page", page);
model.addAttribute("blocks", blocks);
return "page";
}
}
@@ -0,0 +1,54 @@
package com.sisvietnamvn.web.controller;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.repository.PostRepository;
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.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
@Controller
@RequestMapping("/post")
public class PostController {
private final PostRepository postRepository;
public PostController(PostRepository postRepository) {
this.postRepository = postRepository;
}
@GetMapping("/{slug}")
public String viewPost(@PathVariable String slug, Model model) {
Post post = postRepository.findBySlug(slug)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
// Only allow viewing published posts (unless admin, but for now just check published)
if (post.getStatus() != PageStatus.PUBLISHED) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
model.addAttribute("post", post);
// Safely format the Instant to avoid Thymeleaf parsing errors
if (post.getCreatedDate() != null) {
String formattedDate = java.time.format.DateTimeFormatter.ofPattern("MMMM dd, yyyy")
.withZone(java.time.ZoneId.systemDefault())
.format(post.getCreatedDate());
model.addAttribute("formattedDate", formattedDate);
}
switch (post.getLayout()) {
case SIDEBAR:
return "posts/sidebar";
case FULL_WIDTH:
return "posts/full-width";
case STANDARD:
default:
return "posts/standard";
}
}
}
@@ -0,0 +1,141 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.Category;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.service.CategoryService;
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 post Categories in the admin panel.
* Uses a WordPress-style split layout: form on the left, table on the right.
*/
@Controller
@RequestMapping("/manage/posts/categories")
public class ManageCategoryController {
private static final Logger LOG = LoggerFactory.getLogger(ManageCategoryController.class);
private final CategoryService categoryService;
private final HookManager hookManager;
public ManageCategoryController(CategoryService categoryService, HookManager hookManager) {
this.categoryService = categoryService;
this.hookManager = hookManager;
}
@ModelAttribute
public void adminInit() {
hookManager.doAction("admin_init");
}
/**
* GET /manage/posts/categories — List all categories with inline create form.
*/
@GetMapping
public String listCategories(Model model) {
LOG.debug("Request to list all categories");
populateModel(model, new Category(), true);
return "manage/posts/categories";
}
/**
* POST /manage/posts/categories — Save a new category.
*/
@PostMapping
public String createCategory(@Valid @ModelAttribute("category") Category category,
BindingResult bindingResult,
@RequestParam(value = "parentId", required = false) Long parentId,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to create Category : {}", category);
if (bindingResult.hasErrors()) {
populateModel(model, category, true);
return "manage/posts/categories";
}
if (parentId != null) {
categoryService.findById(parentId).ifPresent(category::setParent);
}
categoryService.save(category);
redirectAttributes.addFlashAttribute("successMessage", "Category created successfully!");
return "redirect:/manage/posts/categories";
}
/**
* GET /manage/posts/categories/{id}/edit — Show the edit form for a category.
*/
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
LOG.debug("Request to show edit form for Category : {}", id);
Optional<Category> categoryOptional = categoryService.findById(id);
if (categoryOptional.isEmpty()) {
redirectAttributes.addFlashAttribute("errorMessage", "Category not found.");
return "redirect:/manage/posts/categories";
}
populateModel(model, categoryOptional.get(), false);
return "manage/posts/categories";
}
/**
* POST /manage/posts/categories/{id} — Update an existing category.
*/
@PostMapping("/{id}")
public String updateCategory(@PathVariable Long id,
@Valid @ModelAttribute("category") Category category,
BindingResult bindingResult,
@RequestParam(value = "parentId", required = false) Long parentId,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to update Category : {}", id);
if (bindingResult.hasErrors()) {
populateModel(model, category, false);
return "manage/posts/categories";
}
category.setId(id);
if (parentId != null && !parentId.equals(id)) {
categoryService.findById(parentId).ifPresent(category::setParent);
} else {
category.setParent(null);
}
categoryService.save(category);
redirectAttributes.addFlashAttribute("successMessage", "Category updated successfully!");
return "redirect:/manage/posts/categories";
}
/**
* POST /manage/posts/categories/{id}/delete — Delete a category.
*/
@PostMapping("/{id}/delete")
public String deleteCategory(@PathVariable Long id, RedirectAttributes redirectAttributes) {
LOG.debug("Request to delete Category : {}", id);
try {
categoryService.delete(id);
redirectAttributes.addFlashAttribute("successMessage", "Category deleted successfully!");
} catch (IllegalStateException e) {
redirectAttributes.addFlashAttribute("errorMessage", e.getMessage());
}
return "redirect:/manage/posts/categories";
}
/**
* Populate common model attributes for the categories view.
*/
private void populateModel(Model model, Category category, boolean isNew) {
model.addAttribute("category", category);
model.addAttribute("isNew", isNew);
model.addAttribute("allCategories", categoryService.findAll());
// Build a map of category ID -> post count for the table
java.util.Map<Long, Long> postCounts = new java.util.HashMap<>();
for (Category c : categoryService.findAll()) {
postCounts.put(c.getId(), categoryService.countPosts(c.getId()));
}
model.addAttribute("postCounts", postCounts);
}
}
@@ -1,16 +1,19 @@
package com.sisvietnamvn.web.controller; package com.sisvietnamvn.web.controller.manage;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
/**
* Controller for the admin dashboard index page.
* Serves the main "/manage" landing page.
*/
@Controller @Controller
@RequestMapping("/manage") @RequestMapping("/manage")
public class ManageController { public class ManageDashboardController {
@GetMapping({"", "/"}) @GetMapping({"", "/"})
public String index() { public String index() {
return "manage/index"; return "manage/index";
} }
} }
@@ -0,0 +1,139 @@
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("pageTypes", com.sisvietnamvn.web.domain.PageType.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("pageTypes", com.sisvietnamvn.web.domain.PageType.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("pageTypes", com.sisvietnamvn.web.domain.PageType.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("pageTypes", com.sisvietnamvn.web.domain.PageType.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";
}
}
@@ -0,0 +1,182 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.PostLayout;
import com.sisvietnamvn.web.domain.Tag;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.service.CategoryService;
import com.sisvietnamvn.web.service.PostService;
import com.sisvietnamvn.web.service.TagService;
import jakarta.validation.Valid;
import java.util.Optional;
import java.util.stream.Collectors;
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 blog Posts in the admin panel.
* Provides CRUD operations with category and tag management.
*/
@Controller
@RequestMapping("/manage/posts")
public class ManagePostController {
private static final Logger LOG = LoggerFactory.getLogger(ManagePostController.class);
private final PostService postService;
private final CategoryService categoryService;
private final TagService tagService;
private final HookManager hookManager;
public ManagePostController(PostService postService,
CategoryService categoryService,
TagService tagService,
HookManager hookManager) {
this.postService = postService;
this.categoryService = categoryService;
this.tagService = tagService;
this.hookManager = hookManager;
}
@ModelAttribute
public void adminInit() {
hookManager.doAction("admin_init");
}
/**
* GET /manage/posts — List all posts with optional filters.
*/
@GetMapping
public String listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "status", required = false) String statusStr,
Model model) {
LOG.debug("Request to list all posts (categoryId={}, status={})", categoryId, statusStr);
PageStatus status = null;
if (statusStr != null && !statusStr.isBlank()) {
try {
status = PageStatus.valueOf(statusStr);
} catch (IllegalArgumentException e) {
LOG.warn("Invalid status filter: {}", statusStr);
}
}
model.addAttribute("posts", postService.findFiltered(categoryId, status));
model.addAttribute("categories", categoryService.findAll());
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("selectedCategoryId", categoryId);
model.addAttribute("selectedStatus", statusStr);
return "manage/posts/list";
}
/**
* GET /manage/posts/new — Show the "Add New Post" form.
*/
@GetMapping("/new")
public String showCreateForm(Model model) {
LOG.debug("Request to show create post form");
Post post = new Post();
post.setStatus(PageStatus.DRAFT);
populateFormModel(model, post, "", true);
return "manage/posts/form";
}
/**
* POST /manage/posts/create — Save a new post.
*/
@PostMapping("/create")
public String createPost(@Valid @ModelAttribute("post") Post post,
BindingResult bindingResult,
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "tagNames", required = false, defaultValue = "") String tagNames,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to create Post : {}", post);
if (bindingResult.hasErrors()) {
populateFormModel(model, post, tagNames, true);
return "manage/posts/form";
}
if (categoryId != null) {
categoryService.findById(categoryId).ifPresent(post::setCategory);
}
postService.saveWithTags(post, tagNames);
redirectAttributes.addFlashAttribute("successMessage", "Post created successfully!");
return "redirect:/manage/posts";
}
/**
* GET /manage/posts/{id}/edit — Show the "Edit Post" form.
*/
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
LOG.debug("Request to show edit form for Post : {}", id);
Optional<Post> postOptional = postService.findById(id);
if (postOptional.isEmpty()) {
redirectAttributes.addFlashAttribute("errorMessage", "Post not found.");
return "redirect:/manage/posts";
}
Post post = postOptional.get();
String tagNames = post.getTags().stream()
.map(Tag::getName)
.collect(Collectors.joining(", "));
populateFormModel(model, post, tagNames, false);
return "manage/posts/form";
}
/**
* POST /manage/posts/{id} — Update an existing post.
*/
@PostMapping("/{id}")
public String updatePost(@PathVariable Long id,
@Valid @ModelAttribute("post") Post post,
BindingResult bindingResult,
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "tagNames", required = false, defaultValue = "") String tagNames,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to update Post : {}", id);
if (bindingResult.hasErrors()) {
populateFormModel(model, post, tagNames, false);
return "manage/posts/form";
}
post.setId(id);
if (categoryId != null) {
categoryService.findById(categoryId).ifPresent(post::setCategory);
} else {
post.setCategory(null);
}
postService.saveWithTags(post, tagNames);
redirectAttributes.addFlashAttribute("successMessage", "Post updated successfully!");
return "redirect:/manage/posts";
}
/**
* POST /manage/posts/{id}/delete — Delete a post.
*/
@PostMapping("/{id}/delete")
public String deletePost(@PathVariable Long id, RedirectAttributes redirectAttributes) {
LOG.debug("Request to delete Post : {}", id);
postService.delete(id);
redirectAttributes.addFlashAttribute("successMessage", "Post deleted successfully!");
return "redirect:/manage/posts";
}
/**
* Populate common model attributes for the post form.
*/
private void populateFormModel(Model model, Post post, String tagNames, boolean isNew) {
model.addAttribute("post", post);
model.addAttribute("tagNames", tagNames);
model.addAttribute("isNew", isNew);
model.addAttribute("categories", categoryService.findAll());
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("layouts", PostLayout.values());
model.addAttribute("allTags", tagService.findAll());
}
}
@@ -0,0 +1,127 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.Tag;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.service.TagService;
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 post Tags in the admin panel.
* Uses a WordPress-style split layout: form on the left, table on the right.
*/
@Controller
@RequestMapping("/manage/posts/tags")
public class ManageTagController {
private static final Logger LOG = LoggerFactory.getLogger(ManageTagController.class);
private final TagService tagService;
private final HookManager hookManager;
public ManageTagController(TagService tagService, HookManager hookManager) {
this.tagService = tagService;
this.hookManager = hookManager;
}
@ModelAttribute
public void adminInit() {
hookManager.doAction("admin_init");
}
/**
* GET /manage/posts/tags — List all tags with inline create form.
*/
@GetMapping
public String listTags(Model model) {
LOG.debug("Request to list all tags");
populateModel(model, new Tag(), true);
return "manage/posts/tags";
}
/**
* POST /manage/posts/tags — Save a new tag.
*/
@PostMapping
public String createTag(@Valid @ModelAttribute("tag") Tag tag,
BindingResult bindingResult,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to create Tag : {}", tag);
if (bindingResult.hasErrors()) {
populateModel(model, tag, true);
return "manage/posts/tags";
}
tagService.save(tag);
redirectAttributes.addFlashAttribute("successMessage", "Tag created successfully!");
return "redirect:/manage/posts/tags";
}
/**
* GET /manage/posts/tags/{id}/edit — Show the edit form for a tag.
*/
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
LOG.debug("Request to show edit form for Tag : {}", id);
Optional<Tag> tagOptional = tagService.findById(id);
if (tagOptional.isEmpty()) {
redirectAttributes.addFlashAttribute("errorMessage", "Tag not found.");
return "redirect:/manage/posts/tags";
}
populateModel(model, tagOptional.get(), false);
return "manage/posts/tags";
}
/**
* POST /manage/posts/tags/{id} — Update an existing tag.
*/
@PostMapping("/{id}")
public String updateTag(@PathVariable Long id,
@Valid @ModelAttribute("tag") Tag tag,
BindingResult bindingResult,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to update Tag : {}", id);
if (bindingResult.hasErrors()) {
populateModel(model, tag, false);
return "manage/posts/tags";
}
tag.setId(id);
tagService.save(tag);
redirectAttributes.addFlashAttribute("successMessage", "Tag updated successfully!");
return "redirect:/manage/posts/tags";
}
/**
* POST /manage/posts/tags/{id}/delete — Delete a tag.
*/
@PostMapping("/{id}/delete")
public String deleteTag(@PathVariable Long id, RedirectAttributes redirectAttributes) {
LOG.debug("Request to delete Tag : {}", id);
tagService.delete(id);
redirectAttributes.addFlashAttribute("successMessage", "Tag deleted successfully!");
return "redirect:/manage/posts/tags";
}
/**
* Populate common model attributes for the tags view.
*/
private void populateModel(Model model, Tag tag, boolean isNew) {
model.addAttribute("tag", tag);
model.addAttribute("isNew", isNew);
model.addAttribute("allTags", tagService.findAll());
// Build a map of tag ID -> post count for the table
java.util.Map<Long, Long> postCounts = new java.util.HashMap<>();
for (Tag t : tagService.findAll()) {
postCounts.put(t.getId(), tagService.countPosts(t.getId()));
}
model.addAttribute("postCounts", postCounts);
}
}
@@ -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);
}
}
}
@@ -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");
}
}
}
@@ -74,4 +74,12 @@ public abstract class AbstractAuditingEntity<T> implements Serializable {
public void setLastModifiedDate(Instant lastModifiedDate) { public void setLastModifiedDate(Instant lastModifiedDate) {
this.lastModifiedDate = lastModifiedDate; this.lastModifiedDate = lastModifiedDate;
} }
public java.util.Date getCreatedDateAsDate() {
return createdDate != null ? java.util.Date.from(createdDate) : null;
}
public java.util.Date getLastModifiedDateAsDate() {
return lastModifiedDate != null ? java.util.Date.from(lastModifiedDate) : null;
}
} }
@@ -0,0 +1,110 @@
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 Category entity for classifying blog posts.
* Supports hierarchical structure via self-referencing parent relationship.
* Maps to the "sis_category" database table.
*/
@Entity
@Table(name = "sis_category")
public class Category 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 = "name", length = 255, nullable = false)
private String name;
@NotNull
@Size(max = 255)
@Column(name = "slug", length = 255, nullable = false, unique = true)
private String slug;
@Size(max = 1000)
@Column(name = "description", length = 1000)
private String description;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
// --- Getters and Setters ---
@Override
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Category getParent() {
return parent;
}
public void setParent(Category parent) {
this.parent = parent;
}
// --- equals, hashCode, toString ---
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Category category)) return false;
return id != null && id.equals(category.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Category{" +
"id=" + getId() +
", name='" + getName() + "'" +
", slug='" + getSlug() + "'" +
", description='" + getDescription() + "'" +
"}";
}
}
@@ -0,0 +1,150 @@
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;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "page_type", length = 50, nullable = false)
private PageType pageType = PageType.CUSTOM;
@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 PageType getPageType() {
return pageType;
}
public void setPageType(PageType pageType) {
this.pageType = pageType;
}
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() + "'" +
", pageType='" + getPageType() + "'" +
", 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,21 @@
package com.sisvietnamvn.web.domain;
/**
* The PageType enumeration.
* Specifies the template or system location that a CMS page belongs to.
*/
public enum PageType {
HOME,
ABOUT_US,
SPECIALTY,
DOCTOR,
SERVICE,
HEALTH_LIBRARY,
NEWS_AND_EVENTS,
PATIENT_SUPPORT,
MEDICAL_EXPERT,
UMCERS,
BIDDING,
CONTACT_US,
CUSTOM
}
@@ -0,0 +1,189 @@
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.HashSet;
import java.util.Objects;
import java.util.Set;
/**
* A Post entity representing a blog/news article.
* Maps to the "sis_post" database table.
*/
@Entity
@Table(name = "sis_post")
public class Post 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 = 1000)
@Column(name = "excerpt", length = 1000)
private String excerpt;
@Size(max = 500)
@Column(name = "featured_image", length = 500)
private String featuredImage;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 20, nullable = false)
private PageStatus status = PageStatus.DRAFT;
@Size(max = 500)
@Column(name = "meta_description", length = 500)
private String metaDescription;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "layout", length = 20, nullable = false)
private PostLayout layout = PostLayout.STANDARD;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "sis_post_tag",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private Set<Tag> tags = new HashSet<>();
// --- 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 getExcerpt() {
return excerpt;
}
public void setExcerpt(String excerpt) {
this.excerpt = excerpt;
}
public String getFeaturedImage() {
return featuredImage;
}
public void setFeaturedImage(String featuredImage) {
this.featuredImage = featuredImage;
}
public PageStatus getStatus() {
return status;
}
public void setStatus(PageStatus status) {
this.status = status;
}
public String getMetaDescription() {
return metaDescription;
}
public void setMetaDescription(String metaDescription) {
this.metaDescription = metaDescription;
}
public PostLayout getLayout() {
return layout;
}
public void setLayout(PostLayout layout) {
this.layout = layout;
}
public Category getCategory() {
return category;
}
public void setCategory(Category category) {
this.category = category;
}
public Set<Tag> getTags() {
return tags;
}
public void setTags(Set<Tag> tags) {
this.tags = tags;
}
// --- equals, hashCode, toString ---
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Post post)) return false;
return id != null && id.equals(post.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Post{" +
"id=" + getId() +
", title='" + getTitle() + "'" +
", slug='" + getSlug() + "'" +
", status='" + getStatus() + "'" +
"}";
}
}
@@ -0,0 +1,10 @@
package com.sisvietnamvn.web.domain;
/**
* Enumeration for Post Layout options.
*/
public enum PostLayout {
STANDARD,
SIDEBAR,
FULL_WIDTH
}
@@ -0,0 +1,84 @@
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 Tag entity for labeling blog posts with keywords.
* Maps to the "sis_tag" database table.
*/
@Entity
@Table(name = "sis_tag")
public class Tag 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 = "name", length = 255, nullable = false, unique = true)
private String name;
@NotNull
@Size(max = 255)
@Column(name = "slug", length = 255, nullable = false, unique = true)
private String slug;
// --- Getters and Setters ---
@Override
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
// --- equals, hashCode, toString ---
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Tag tag)) return false;
return id != null && id.equals(tag.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Tag{" +
"id=" + getId() +
", name='" + getName() + "'" +
", slug='" + getSlug() + "'" +
"}";
}
}
@@ -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,44 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.Category;
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 Category} entity.
*/
@Repository
public interface CategoryRepository extends JpaRepository<Category, Long> {
/**
* Find all categories ordered by name ascending.
*/
List<Category> findAllByOrderByNameAsc();
/**
* Find a category by its URL-friendly slug.
*/
Optional<Category> findBySlug(String slug);
/**
* Check if a slug already exists (for uniqueness validation on create).
*/
boolean existsBySlug(String slug);
/**
* Check if a slug already exists for a different category (for edit validation).
*/
boolean existsBySlugAndIdNot(String slug, Long id);
/**
* Find all top-level categories (no parent).
*/
List<Category> findByParentIsNullOrderByNameAsc();
/**
* Find child categories of a given parent.
*/
List<Category> findByParentIdOrderByNameAsc(Long parentId);
}
@@ -0,0 +1,45 @@
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 a page by its predefined CMS Page Type.
*/
Optional<Page> findByPageType(com.sisvietnamvn.web.domain.PageType pageType);
/**
* 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,80 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.Post;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
/**
* Spring Data JPA repository for the {@link Post} entity.
*/
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
/**
* Find all posts ordered by creation date descending (newest first).
*/
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
List<Post> findAllByOrderByCreatedDateDesc();
/**
* Find a post by its ID with eagerly fetched category and tags.
*/
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
Optional<Post> findById(Long id);
/**
* Find a post by its URL-friendly slug.
*/
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
Optional<Post> findBySlug(String slug);
/**
* Find all posts with a given publication status.
*/
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
List<Post> findByStatusOrderByCreatedDateDesc(PageStatus status);
/**
* Find all posts in a given category.
*/
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
List<Post> findByCategoryIdOrderByCreatedDateDesc(Long categoryId);
/**
* Find all posts in a given category with a given status.
*/
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
List<Post> findByCategoryIdAndStatusOrderByCreatedDateDesc(Long categoryId, PageStatus status);
/**
* Count the number of posts in a given category.
*/
long countByCategoryId(Long categoryId);
/**
* Count the number of posts associated with a given tag.
*/
@Query("SELECT COUNT(p) FROM Post p JOIN p.tags t WHERE t.id = :tagId")
long countByTagId(@Param("tagId") Long tagId);
/**
* Check if a slug already exists (for uniqueness validation on create).
*/
boolean existsBySlug(String slug);
/**
* Check if a slug already exists for a different post (for edit validation).
*/
boolean existsBySlugAndIdNot(String slug, Long id);
/**
* Find all posts that have a specific tag.
*/
@Query("SELECT p FROM Post p JOIN p.tags t WHERE t.id = :tagId ORDER BY p.createdDate DESC")
List<Post> findByTagId(@Param("tagId") Long tagId);
}
@@ -0,0 +1,44 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.Tag;
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 Tag} entity.
*/
@Repository
public interface TagRepository extends JpaRepository<Tag, Long> {
/**
* Find all tags ordered by name ascending.
*/
List<Tag> findAllByOrderByNameAsc();
/**
* Find a tag by its URL-friendly slug.
*/
Optional<Tag> findBySlug(String slug);
/**
* Find a tag by its exact name.
*/
Optional<Tag> findByName(String name);
/**
* Search tags by name containing the given string (for autocomplete).
*/
List<Tag> findByNameContainingIgnoreCase(String name);
/**
* Check if a slug already exists (for uniqueness validation on create).
*/
boolean existsBySlug(String slug);
/**
* Check if a slug already exists for a different tag (for edit validation).
*/
boolean existsBySlugAndIdNot(String slug, Long id);
}
@@ -0,0 +1,123 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.Category;
import com.sisvietnamvn.web.repository.CategoryRepository;
import com.sisvietnamvn.web.repository.PostRepository;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Service class for managing post categories.
*/
@Service
@Transactional
public class CategoryService {
private static final Logger LOG = LoggerFactory.getLogger(CategoryService.class);
private final CategoryRepository categoryRepository;
private final PostRepository postRepository;
public CategoryService(CategoryRepository categoryRepository, PostRepository postRepository) {
this.categoryRepository = categoryRepository;
this.postRepository = postRepository;
}
/**
* Get all categories sorted by name.
*/
@Transactional(readOnly = true)
public List<Category> findAll() {
LOG.debug("Request to get all Categories");
return categoryRepository.findAllByOrderByNameAsc();
}
/**
* Get a single category by ID.
*/
@Transactional(readOnly = true)
public Optional<Category> findById(Long id) {
LOG.debug("Request to get Category : {}", id);
return categoryRepository.findById(id);
}
/**
* Get a single category by its URL slug.
*/
@Transactional(readOnly = true)
public Optional<Category> findBySlug(String slug) {
LOG.debug("Request to get Category by slug : {}", slug);
return categoryRepository.findBySlug(slug);
}
/**
* Get top-level categories (no parent).
*/
@Transactional(readOnly = true)
public List<Category> findTopLevel() {
LOG.debug("Request to get top-level Categories");
return categoryRepository.findByParentIsNullOrderByNameAsc();
}
/**
* Count the number of posts in a category.
*/
@Transactional(readOnly = true)
public long countPosts(Long categoryId) {
return postRepository.countByCategoryId(categoryId);
}
/**
* Save a category (create or update).
* Auto-generates a URL slug from the name if the slug is empty.
*/
public Category save(Category category) {
LOG.debug("Request to save Category : {}", category);
if (category.getSlug() == null || category.getSlug().isBlank()) {
category.setSlug(PageService.generateSlug(category.getName()));
}
// Ensure slug uniqueness
String baseSlug = category.getSlug();
String candidateSlug = baseSlug;
int counter = 1;
while (isSlugTaken(candidateSlug, category.getId())) {
candidateSlug = baseSlug + "-" + counter;
counter++;
}
category.setSlug(candidateSlug);
return categoryRepository.save(category);
}
/**
* Delete a category by ID.
* @throws IllegalStateException if the category still has posts assigned to it.
*/
public void delete(Long id) {
LOG.debug("Request to delete Category : {}", id);
long postCount = postRepository.countByCategoryId(id);
if (postCount > 0) {
throw new IllegalStateException(
"Cannot delete category: " + postCount + " post(s) are still assigned to it. " +
"Please reassign or delete those posts first."
);
}
categoryRepository.deleteById(id);
}
/**
* Check if a slug is already taken by another category.
*/
private boolean isSlugTaken(String slug, Long currentCategoryId) {
if (currentCategoryId == null) {
return categoryRepository.existsBySlug(slug);
}
return categoryRepository.existsBySlugAndIdNot(slug, currentCategoryId);
}
}
@@ -0,0 +1,161 @@
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);
}
/**
* Get a single page by its predefined CMS Page Type.
*/
@Transactional(readOnly = true)
public Optional<Page> findByPageType(com.sisvietnamvn.web.domain.PageType pageType) {
LOG.debug("Request to get Page by type : {}", pageType);
return pageRepository.findByPageType(pageType);
}
/**
* 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);
}
}
@@ -0,0 +1,174 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.Tag;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.repository.PostRepository;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Service class for managing blog posts.
*/
@Service
@Transactional
public class PostService {
private static final Logger LOG = LoggerFactory.getLogger(PostService.class);
private final PostRepository postRepository;
private final TagService tagService;
private final HookManager hookManager;
public PostService(PostRepository postRepository, TagService tagService, HookManager hookManager) {
this.postRepository = postRepository;
this.tagService = tagService;
this.hookManager = hookManager;
}
/**
* Get all posts ordered by creation date (newest first).
*/
@Transactional(readOnly = true)
public List<Post> findAll() {
LOG.debug("Request to get all Posts");
return postRepository.findAllByOrderByCreatedDateDesc();
}
/**
* Get a single post by ID.
*/
@Transactional(readOnly = true)
public Optional<Post> findById(Long id) {
LOG.debug("Request to get Post : {}", id);
return postRepository.findById(id);
}
/**
* Get a single post by its URL slug.
*/
@Transactional(readOnly = true)
public Optional<Post> findBySlug(String slug) {
LOG.debug("Request to get Post by slug : {}", slug);
return postRepository.findBySlug(slug);
}
/**
* Find all posts with a specific status.
*/
@Transactional(readOnly = true)
public List<Post> findByStatus(PageStatus status) {
LOG.debug("Request to get Posts by status : {}", status);
return postRepository.findByStatusOrderByCreatedDateDesc(status);
}
/**
* Find all posts in a specific category.
*/
@Transactional(readOnly = true)
public List<Post> findByCategoryId(Long categoryId) {
LOG.debug("Request to get Posts by category : {}", categoryId);
return postRepository.findByCategoryIdOrderByCreatedDateDesc(categoryId);
}
/**
* Find all posts filtered by category and/or status.
*/
@Transactional(readOnly = true)
public List<Post> findFiltered(Long categoryId, PageStatus status) {
LOG.debug("Request to get Posts filtered by categoryId={}, status={}", categoryId, status);
if (categoryId != null && status != null) {
return postRepository.findByCategoryIdAndStatusOrderByCreatedDateDesc(categoryId, status);
} else if (categoryId != null) {
return postRepository.findByCategoryIdOrderByCreatedDateDesc(categoryId);
} else if (status != null) {
return postRepository.findByStatusOrderByCreatedDateDesc(status);
}
return postRepository.findAllByOrderByCreatedDateDesc();
}
/**
* Save a post (create or update).
* Auto-generates a URL slug from the title if the slug is empty.
*/
public Post save(Post post) {
LOG.debug("Request to save Post : {}", post);
// Hook: pre_save_post filter
post = hookManager.applyFilters("pre_save_post", post);
if (post.getSlug() == null || post.getSlug().isBlank()) {
post.setSlug(PageService.generateSlug(post.getTitle()));
}
// Ensure slug uniqueness
String baseSlug = post.getSlug();
String candidateSlug = baseSlug;
int counter = 1;
while (isSlugTaken(candidateSlug, post.getId())) {
candidateSlug = baseSlug + "-" + counter;
counter++;
}
// Hook: post_slug filter
candidateSlug = hookManager.applyFilters("post_slug", candidateSlug);
post.setSlug(candidateSlug);
Post savedPost = postRepository.save(post);
// Hook: save_post action
hookManager.doAction("save_post", savedPost);
return savedPost;
}
/**
* Save a post and process comma-separated tag names.
* Auto-creates tags that don't exist yet.
*
* @param post the post entity
* @param tagNames comma-separated tag names (e.g. "news, health, update")
*/
public Post saveWithTags(Post post, String tagNames) {
Set<Tag> tags = new HashSet<>();
if (tagNames != null && !tagNames.isBlank()) {
String[] names = tagNames.split(",");
for (String name : names) {
String trimmed = name.trim();
if (!trimmed.isEmpty()) {
tags.add(tagService.findOrCreateByName(trimmed));
}
}
}
post.setTags(tags);
return save(post);
}
/**
* Delete a post by ID.
*/
public void delete(Long id) {
LOG.debug("Request to delete Post : {}", id);
postRepository.deleteById(id);
// Hook: deleted_post action
hookManager.doAction("deleted_post", id);
}
/**
* Check if a slug is already taken by another post.
*/
private boolean isSlugTaken(String slug, Long currentPostId) {
if (currentPostId == null) {
return postRepository.existsBySlug(slug);
}
return postRepository.existsBySlugAndIdNot(slug, currentPostId);
}
}
@@ -0,0 +1,131 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.Tag;
import com.sisvietnamvn.web.repository.PostRepository;
import com.sisvietnamvn.web.repository.TagRepository;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Service class for managing post tags.
*/
@Service
@Transactional
public class TagService {
private static final Logger LOG = LoggerFactory.getLogger(TagService.class);
private final TagRepository tagRepository;
private final PostRepository postRepository;
public TagService(TagRepository tagRepository, PostRepository postRepository) {
this.tagRepository = tagRepository;
this.postRepository = postRepository;
}
/**
* Get all tags sorted by name.
*/
@Transactional(readOnly = true)
public List<Tag> findAll() {
LOG.debug("Request to get all Tags");
return tagRepository.findAllByOrderByNameAsc();
}
/**
* Get a single tag by ID.
*/
@Transactional(readOnly = true)
public Optional<Tag> findById(Long id) {
LOG.debug("Request to get Tag : {}", id);
return tagRepository.findById(id);
}
/**
* Get a single tag by its URL slug.
*/
@Transactional(readOnly = true)
public Optional<Tag> findBySlug(String slug) {
LOG.debug("Request to get Tag by slug : {}", slug);
return tagRepository.findBySlug(slug);
}
/**
* Find or create a tag by name.
* Used when processing comma-separated tag input from the post editor.
*/
public Tag findOrCreateByName(String name) {
String trimmed = name.trim();
LOG.debug("Request to find or create Tag by name : {}", trimmed);
return tagRepository.findByName(trimmed)
.orElseGet(() -> {
Tag tag = new Tag();
tag.setName(trimmed);
tag.setSlug(PageService.generateSlug(trimmed));
return save(tag);
});
}
/**
* Search tags by name (for autocomplete).
*/
@Transactional(readOnly = true)
public List<Tag> searchByName(String query) {
LOG.debug("Request to search Tags by name : {}", query);
return tagRepository.findByNameContainingIgnoreCase(query);
}
/**
* Count the number of posts associated with a tag.
*/
@Transactional(readOnly = true)
public long countPosts(Long tagId) {
return postRepository.countByTagId(tagId);
}
/**
* Save a tag (create or update).
* Auto-generates a URL slug from the name if the slug is empty.
*/
public Tag save(Tag tag) {
LOG.debug("Request to save Tag : {}", tag);
if (tag.getSlug() == null || tag.getSlug().isBlank()) {
tag.setSlug(PageService.generateSlug(tag.getName()));
}
// Ensure slug uniqueness
String baseSlug = tag.getSlug();
String candidateSlug = baseSlug;
int counter = 1;
while (isSlugTaken(candidateSlug, tag.getId())) {
candidateSlug = baseSlug + "-" + counter;
counter++;
}
tag.setSlug(candidateSlug);
return tagRepository.save(tag);
}
/**
* Delete a tag by ID.
*/
public void delete(Long id) {
LOG.debug("Request to delete Tag : {}", id);
tagRepository.deleteById(id);
}
/**
* Check if a slug is already taken by another tag.
*/
private boolean isSlugTaken(String slug, Long currentTagId) {
if (currentTagId == null) {
return tagRepository.existsBySlug(slug);
}
return tagRepository.existsBySlugAndIdNot(slug, currentTagId);
}
}
@@ -14,6 +14,8 @@
# =================================================================== # ===================================================================
logging: logging:
file:
name: application.log
level: level:
ROOT: DEBUG ROOT: DEBUG
tech.jhipster: DEBUG tech.jhipster: DEBUG
@@ -24,7 +26,7 @@ spring:
devtools: devtools:
restart: restart:
enabled: true enabled: true
additional-exclude: static/**,.h2.server.properties additional-exclude: static/**
livereload: livereload:
enabled: true # enabled for backend-only app (run ./gradlew classes -t) enabled: true # enabled for backend-only app (run ./gradlew classes -t)
jackson2: jackson2:
@@ -32,16 +34,15 @@ spring:
indent-output: true indent-output: true
datasource: datasource:
type: com.zaxxer.hikari.HikariDataSource type: com.zaxxer.hikari.HikariDataSource
url: jdbc:oracle:thin:@192.168.1.15:1521/sisvietnam url: jdbc:oracle:thin:@localhost:1521/FREEPDB1
username: sisvietnam username: sisvietnam
password: "sisvietnam" password: "sisvietnam"
hikari: hikari:
poolName: Hikari poolName: Hikari
auto-commit: false auto-commit: false
h2: jpa:
console: properties:
# JHipster uses a custom h2-console initializer hibernate.dialect: org.hibernate.dialect.OracleDialect
enabled: false
liquibase: liquibase:
# Remove 'faker' if you do not want the sample data to be loaded automatically # Remove 'faker' if you do not want the sample data to be loaded automatically
contexts: dev, faker contexts: dev, faker
@@ -86,6 +87,7 @@ jhipster:
# Token is valid 24 hours # Token is valid 24 hours
token-validity-in-seconds: 86400 token-validity-in-seconds: 86400
token-validity-in-seconds-for-remember-me: 2592000 token-validity-in-seconds-for-remember-me: 2592000
base64-secret: NGE0ZDlkMmI4YTMzMTNjOGFiZWZiMGMwNmVkNTcxNDZhZmVmMThmMDViNGMzNWI3MGVjY2YyZWJmOTMzOGI2NTIzMDdhMDVlNzU0YWJkYjcxOGM3Y2IwODcyZmMyZjg1MDU5ZjViMDIzMDJmMjc3ZmYxMWMyZDdiZWNhMmVjNGE=
mail: # specific JHipster mail property, for standard properties see MailProperties mail: # specific JHipster mail property, for standard properties see MailProperties
base-url: http://127.0.0.1:8080 base-url: http://127.0.0.1:8080
logging: logging:
@@ -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>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260625120000-1" author="antigravity">
<addColumn tableName="sis_page">
<column name="page_type" type="varchar(50)" defaultValue="CUSTOM">
<constraints nullable="false" />
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,126 @@
<?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">
<!-- ============================== -->
<!-- 1. Create sis_category table -->
<!-- ============================== -->
<changeSet id="20260628100000-1" author="sisvietnam">
<createTable tableName="sis_category">
<column name="id" type="bigint">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="slug" type="varchar(255)">
<constraints nullable="false" unique="true" uniqueConstraintName="ux_sis_category_slug"/>
</column>
<column name="description" type="varchar(1000)"/>
<column name="parent_id" type="bigint"/>
<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>
<addForeignKeyConstraint baseTableName="sis_category"
baseColumnNames="parent_id"
constraintName="fk_category_parent"
referencedTableName="sis_category"
referencedColumnNames="id"/>
</changeSet>
<!-- ============================== -->
<!-- 2. Create sis_tag table -->
<!-- ============================== -->
<changeSet id="20260628100000-2" author="sisvietnam">
<createTable tableName="sis_tag">
<column name="id" type="bigint">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="varchar(255)">
<constraints nullable="false" unique="true" uniqueConstraintName="ux_sis_tag_name"/>
</column>
<column name="slug" type="varchar(255)">
<constraints nullable="false" unique="true" uniqueConstraintName="ux_sis_tag_slug"/>
</column>
<column name="created_by" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="created_date" type="timestamp"/>
<column name="last_modified_by" type="varchar(50)"/>
<column name="last_modified_date" type="timestamp"/>
</createTable>
</changeSet>
<!-- ============================== -->
<!-- 3. Create sis_post table -->
<!-- ============================== -->
<changeSet id="20260628100000-3" author="sisvietnam">
<createTable tableName="sis_post">
<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_post_slug"/>
</column>
<column name="content" type="${clobType}"/>
<column name="excerpt" type="varchar(1000)"/>
<column name="featured_image" type="varchar(500)"/>
<column name="status" type="varchar(20)" defaultValue="DRAFT">
<constraints nullable="false"/>
</column>
<column name="meta_description" type="varchar(500)"/>
<column name="category_id" type="bigint"/>
<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>
<addForeignKeyConstraint baseTableName="sis_post"
baseColumnNames="category_id"
constraintName="fk_post_category"
referencedTableName="sis_category"
referencedColumnNames="id"/>
</changeSet>
<!-- ============================== -->
<!-- 4. Create sis_post_tag table -->
<!-- ============================== -->
<changeSet id="20260628100000-4" author="sisvietnam">
<createTable tableName="sis_post_tag">
<column name="post_id" type="bigint">
<constraints nullable="false"/>
</column>
<column name="tag_id" type="bigint">
<constraints nullable="false"/>
</column>
</createTable>
<addPrimaryKey tableName="sis_post_tag" columnNames="post_id, tag_id"/>
<addForeignKeyConstraint baseTableName="sis_post_tag"
baseColumnNames="post_id"
constraintName="fk_post_tag_post"
referencedTableName="sis_post"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="sis_post_tag"
baseColumnNames="tag_id"
constraintName="fk_post_tag_tag"
referencedTableName="sis_tag"
referencedColumnNames="id"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260628151700-1" author="antigravity">
<addColumn tableName="sis_post">
<column name="layout" type="varchar(20)" defaultValue="STANDARD">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -19,6 +19,10 @@
<property name="timeType" value="time" dbms="oracle"/> <property name="timeType" value="time" dbms="oracle"/>
<include file="config/liquibase/changelog/00000000000000_initial_schema.xml" relativeToChangelogFile="false"/> <include file="config/liquibase/changelog/00000000000000_initial_schema.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260625100000_add_page_entity.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260625120000_add_page_type.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260628100000_add_post_category_tag.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260628151700_add_post_layout.xml" relativeToChangelogFile="false"/>
<!-- jhipster-needle-liquibase-add-changelog - JHipster will add liquibase changelogs here --> <!-- jhipster-needle-liquibase-add-changelog - JHipster will add liquibase changelogs here -->
<!-- jhipster-needle-liquibase-add-constraints-changelog - JHipster will add liquibase constraints changelogs here --> <!-- jhipster-needle-liquibase-add-constraints-changelog - JHipster will add liquibase constraints changelogs here -->
<!-- jhipster-needle-liquibase-add-incremental-changelog - JHipster will add incremental liquibase changelogs here --> <!-- jhipster-needle-liquibase-add-incremental-changelog - JHipster will add incremental liquibase changelogs here -->
@@ -1,4 +1,40 @@
/* Custom CSS - This file has the highest priority and will override other styles */ /* 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"] { [data-component-id="umass_base:tophat"] {
background-color: transparent !important; background-color: transparent !important;
} }
File diff suppressed because one or more lines are too long
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;
}
@@ -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 @@
<footer id="l--main-footer" class="site-footer" th:fragment="footer"> <footer id="l--main-footer" class="site-footer">
<h2 class="visually-hidden">Site Footer</h2> <h2 class="visually-hidden">Site Footer</h2>
@@ -13,15 +13,12 @@
<div class="branding-container"> <div class="branding-container">
<div id="block-footerbranding" <div id="block-footerbranding"
class="block block-fixed-block-content block-fixed-block-contentfooter-branding cc--component-container cc--footer-branding"> class="block block-fixed-block-content block-fixed-block-contentfooter-branding cc--component-container cc--footer-branding">
<div class="c--component c--footer-branding"> <div class="c--component c--footer-branding" style="flex-shrink: 0;">
<a class="logo" href="#" aria-label="SIS"> <a class="logo" href="/" aria-label="SIS Vietnam" style="display: inline-block;">
<img class="whitetext" th:src="@{/images/LogoSIS.svg}" alt="SIS Vietnam"
<div> style="height: 180px; width: auto; max-width: none;">
<img src="https://sisvietnam.vn/wp-content/uploads/2026/06/LogoSIS-1.png" alt="Logo SIS" />
</div>
</a> </a>
</div> </div>
</div> </div>
@@ -37,8 +34,8 @@
<nav aria-label="Social media links"> <nav aria-label="Social media links">
<ul class="social-media-links--platforms platforms"> <ul class="social-media-links--platforms platforms">
<li> <li>
<a href="#" class="ext" target="_blank" rel="noopener noreferrer" <a href="https://www.facebook.com/UMassAmherst/" class="ext" target="_blank"
aria-label="Facebook (opens in new window)"> rel="noopener noreferrer" aria-label="Facebook (opens in new window)">
<svg height="16" viewBox="0 0 16 16" width="16" aria-hidden="true" focusable="false"> <svg height="16" viewBox="0 0 16 16" width="16" aria-hidden="true" focusable="false">
<path <path
d="m19.9593333 5h-14.0853333c-.48269687 0-.874.39130313-.874.874v14.0853333c0 .4826969.39130313.874.874.874h7.5835333v-6.1230666h-2.0564333v-2.3965334h2.0564333v-1.7625666c0-2.04503337 1.2489334-3.15843337 3.0735667-3.15843337.6158056-.00219435 1.23127.02930829 1.8436333.09436667v2.13623333h-1.2616c-.9899 0-1.1811666.47119997-1.1811666 1.16343337v1.5263333h2.3731l-.3090667 2.3965333h-2.0640333v6.1237h4.0273666c.4826969 0 .874-.3913031.874-.874v-14.0853333c0-.48269687-.3913031-.874-.874-.874z" d="m19.9593333 5h-14.0853333c-.48269687 0-.874.39130313-.874.874v14.0853333c0 .4826969.39130313.874.874.874h7.5835333v-6.1230666h-2.0564333v-2.3965334h2.0564333v-1.7625666c0-2.04503337 1.2489334-3.15843337 3.0735667-3.15843337.6158056-.00219435 1.23127.02930829 1.8436333.09436667v2.13623333h-1.2616c-.9899 0-1.1811666.47119997-1.1811666 1.16343337v1.5263333h2.3731l-.3090667 2.3965333h-2.0640333v6.1237h4.0273666c.4826969 0 .874-.3913031.874-.874v-14.0853333c0-.48269687-.3913031-.874-.874-.874z"
@@ -71,8 +68,8 @@
</a> </a>
</li> </li>
<li> <li>
<a href="#" class="ext" target="_blank" rel="noopener noreferrer" <a href="https://www.instagram.com/umass/" class="ext" target="_blank"
aria-label="Instagram (opens in new window)"> rel="noopener noreferrer" aria-label="Instagram (opens in new window)">
<svg height="16" viewBox="0 0 16 16" width="16" aria-hidden="true" focusable="false"> <svg height="16" viewBox="0 0 16 16" width="16" aria-hidden="true" focusable="false">
<path <path
d="m12.9166667 5c-2.1666667 0-2.4166667 0-3.25000003.08333333-.83333334 0-1.41666667.16666667-1.91666667.33333334-.5.16666666-1 .5-1.41666667.91666666-.41666666.41666667-.75.91666667-.91666666 1.41666667-.16666667.5-.33333334 1.08333333-.33333334 1.91666667-.08333333.83333333-.08333333 1.08333333-.08333333 3.25000003 0 2.1666666 0 2.4166666.08333333 3.25 0 .8333333.16666667 1.4166666.33333334 1.9166666.16666666.5.5 1 .91666666 1.4166667.41666667.4166667.91666667.75 1.41666667.9166667.5.1666666 1.08333333.3333333 1.91666667.3333333.83333333 0 1.08333333.0833333 3.25000003.0833333 2.1666666 0 2.4166666 0 3.25-.0833333.8333333 0 1.4166666-.1666667 1.9166666-.3333333.5-.1666667 1-.5 1.4166667-.9166667s.75-.9166667.9166667-1.4166667c.1666666-.5.3333333-1.0833333.3333333-1.9166666 0-.8333334.0833333-1.0833334.0833333-3.25 0-2.1666667 0-2.4166667-.0833333-3.25000003 0-.83333334-.1666667-1.41666667-.3333333-1.91666667-.1666667-.5-.5-1-.9166667-1.41666667-.4166667-.41666666-.9166667-.75-1.4166667-.91666666-.5-.16666667-1.0833333-.33333334-1.9166666-.33333334-.8333334-.08333333-1.0833334-.08333333-3.25-.08333333m0 1.41666667c2.0833333 0 2.3333333 0 3.1666666.08333333.75 0 1.1666667.16666667 1.5.25.3333334.16666667.6666667.33333333.9166667.58333333s.4166667.58333334.5833333.91666667c.0833334.25.25.66666667.25 1.5 0 .8333333.0833334 1.0833333.0833334 3.25 0 2.0833333 0 2.3333333-.0833334 3.1666667 0 .75-.1666666 1.1666666-.25 1.5-.1666666.3333333-.3333333.6666666-.5833333.9166666s-.5.4166667-.9166667.5833334c-.25.0833333-.6666666.25-1.5.25-.8333333 0-1.0833333.0833333-3.1666666.0833333-2.0833334 0-2.3333334 0-3.1666667-.0833333-.75 0-1.16666667-.1666667-1.5-.25-.33333333-.1666667-.66666667-.3333334-.91666667-.5833334s-.41666666-.5833333-.58333333-.9166666c-.08333333-.25-.25-.6666667-.25-1.5 0-.8333334-.08333333-1.0833334-.08333333-3.1666667s0-2.3333333.08333333-3.25c0-.75.16666667-1.16666667.25-1.5.16666667-.33333333.33333333-.66666667.58333333-.91666667s.5-.41666666.91666667-.58333333c.25-.08333333.66666667-.25 1.5-.25.8333333-.08333333 1.0833333-.08333333 3.1666667-.08333333m0 9.16666663c-1.4166667 0-2.6666667-1.1666666-2.6666667-2.6666666s1.1666667-2.6666667 2.6666667-2.6666667 2.6666666 1.1666667 2.6666666 2.6666667-1.25 2.6666666-2.6666666 2.6666666m0-6.74999997c-2.25 0-4.08333337 1.83333337-4.08333337 4.08333337s1.83333337 4.0833333 4.08333337 4.0833333 4.0833333-1.8333333 4.0833333-4.0833333-1.8333333-4.08333337-4.0833333-4.08333337m5.1666666-.16666666c0 .5-.4166666.91666666-.9166666.91666666s-.9166667-.41666666-.9166667-.91666666.4166667-.91666667.9166667-.91666667.9166666.41666667.9166666.91666667" d="m12.9166667 5c-2.1666667 0-2.4166667 0-3.25000003.08333333-.83333334 0-1.41666667.16666667-1.91666667.33333334-.5.16666666-1 .5-1.41666667.91666666-.41666666.41666667-.75.91666667-.91666666 1.41666667-.16666667.5-.33333334 1.08333333-.33333334 1.91666667-.08333333.83333333-.08333333 1.08333333-.08333333 3.25000003 0 2.1666666 0 2.4166666.08333333 3.25 0 .8333333.16666667 1.4166666.33333334 1.9166666.16666666.5.5 1 .91666666 1.4166667.41666667.4166667.91666667.75 1.41666667.9166667.5.1666666 1.08333333.3333333 1.91666667.3333333.83333333 0 1.08333333.0833333 3.25000003.0833333 2.1666666 0 2.4166666 0 3.25-.0833333.8333333 0 1.4166666-.1666667 1.9166666-.3333333.5-.1666667 1-.5 1.4166667-.9166667s.75-.9166667.9166667-1.4166667c.1666666-.5.3333333-1.0833333.3333333-1.9166666 0-.8333334.0833333-1.0833334.0833333-3.25 0-2.1666667 0-2.4166667-.0833333-3.25000003 0-.83333334-.1666667-1.41666667-.3333333-1.91666667-.1666667-.5-.5-1-.9166667-1.41666667-.4166667-.41666666-.9166667-.75-1.4166667-.91666666-.5-.16666667-1.0833333-.33333334-1.9166666-.33333334-.8333334-.08333333-1.0833334-.08333333-3.25-.08333333m0 1.41666667c2.0833333 0 2.3333333 0 3.1666666.08333333.75 0 1.1666667.16666667 1.5.25.3333334.16666667.6666667.33333333.9166667.58333333s.4166667.58333334.5833333.91666667c.0833334.25.25.66666667.25 1.5 0 .8333333.0833334 1.0833333.0833334 3.25 0 2.0833333 0 2.3333333-.0833334 3.1666667 0 .75-.1666666 1.1666666-.25 1.5-.1666666.3333333-.3333333.6666666-.5833333.9166666s-.5.4166667-.9166667.5833334c-.25.0833333-.6666666.25-1.5.25-.8333333 0-1.0833333.0833333-3.1666666.0833333-2.0833334 0-2.3333334 0-3.1666667-.0833333-.75 0-1.16666667-.1666667-1.5-.25-.33333333-.1666667-.66666667-.3333334-.91666667-.5833334s-.41666666-.5833333-.58333333-.9166666c-.08333333-.25-.25-.6666667-.25-1.5 0-.8333334-.08333333-1.0833334-.08333333-3.1666667s0-2.3333333.08333333-3.25c0-.75.16666667-1.16666667.25-1.5.16666667-.33333333.33333333-.66666667.58333333-.91666667s.5-.41666666.91666667-.58333333c.25-.08333333.66666667-.25 1.5-.25.8333333-.08333333 1.0833333-.08333333 3.1666667-.08333333m0 9.16666663c-1.4166667 0-2.6666667-1.1666666-2.6666667-2.6666666s1.1666667-2.6666667 2.6666667-2.6666667 2.6666666 1.1666667 2.6666666 2.6666667-1.25 2.6666666-2.6666666 2.6666666m0-6.74999997c-2.25 0-4.08333337 1.83333337-4.08333337 4.08333337s1.83333337 4.0833333 4.08333337 4.0833333 4.0833333-1.8333333 4.0833333-4.0833333-1.8333333-4.08333337-4.0833333-4.08333337m5.1666666-.16666666c0 .5-.4166666.91666666-.9166666.91666666s-.9166667-.41666666-.9166667-.91666666.4166667-.91666667.9166667-.91666667.9166666.41666667.9166666.91666667"
@@ -93,8 +90,8 @@
</a> </a>
</li> </li>
<li> <li>
<a href="#" class="ext" target="_blank" rel="noopener noreferrer" <a href="https://www.snapchat.com/add/umassamherst" class="ext" target="_blank"
aria-label="Snapchat (opens in new window)"> rel="noopener noreferrer" aria-label="Snapchat (opens in new window)">
<svg height="17" viewBox="0 0 18 17" width="18" aria-hidden="true" focusable="false"> <svg height="17" viewBox="0 0 18 17" width="18" aria-hidden="true" focusable="false">
<path <path
d="m12.8333333 20.3333333c-.0833333 0-.0833333 0-.1666666 0h-.0833334c-1.0833333 0-1.6666666-.3333333-2.25-.75-.41666663-.3333333-.74999997-.5833333-1.16666663-.5833333-.25 0-.41666667-.0833333-.66666667-.0833333-.33333333 0-.66666667.0833333-.91666667.0833333-.16666666 0-.33333333.0833333-.41666666.0833333-.33333334 0-.5-.25-.5-.4166666 0-.1666667-.08333334-.25-.08333334-.4166667 0-.0833333-.08333333-.25-.08333333-.3333333-1.33333333-.25-2.08333333-.5-2.25-1-.08333333 0-.08333333-.0833334-.08333333-.1666667 0-.25.16666666-.4166667.41666666-.5 2.25-.3333333 3.33333334-2.75 3.33333334-2.8333333.08333333-.25.16666666-.4166667.08333333-.5833334-.08333333-.1666666-.58333333-.3333333-.91666667-.4166666-.08333333 0-.16666666-.0833334-.25-.0833334-.83333333-.3333333-1-.75-.91666666-1.0833333.08333333-.5.83333333-.75 1.25-.5833333.33333333.1666666.58333333.1666666.75.1666666h.25c0-.0833333 0-.25 0-.3333333-.08333334-1.16666667-.25-2.66666667.16666666-3.5 1.16666667-2.66666667 3.66666667-2.83333333 4.33333337-2.83333333h.3333333c.75 0 3.1666667.16666666 4.3333333 2.83333333.4166667.91666667.3333334 2.33333333.25 3.5v.0833333.25h.1666667c.1666667 0 .4166667-.0833333.6666667-.1666666.1666666-.0833334.25-.0833334.4166666-.0833334.1666667 0 .25 0 .4166667.0833334.3333333.0833333.5833333.4166666.5833333.6666666 0 .4166667-.3333333.6666667-.9166666.9166667-.0833334 0-.1666667.0833333-.25.0833333-.3333334.0833334-.8333334.25-.9166667.5-.0833333.1666667 0 .3333334.0833333.5.0833334.25 1.0833334 2.5 3.25 2.8333334.25 0 .4166667.25.4166667.5 0 .0833333 0 .1666666-.0833333.1666666-.1666667.4166667-.9166667.75-2.25 1 0 .0833334-.0833334.25-.0833334.3333334 0 .1666666-.0833333.25-.0833333.4166666-.0833333.25-.25.4166667-.5.4166667-.0833333 0-.25 0-.4166667-.0833333-.25-.0833334-.5-.0833334-.9166666-.0833334-.1666667 0-.4166667 0-.6666667.0833334-.4166667.0833333-.75.3333333-1.1666667.5833333-.6666666.5833333-1.5.8333333-2.5.8333333" d="m12.8333333 20.3333333c-.0833333 0-.0833333 0-.1666666 0h-.0833334c-1.0833333 0-1.6666666-.3333333-2.25-.75-.41666663-.3333333-.74999997-.5833333-1.16666663-.5833333-.25 0-.41666667-.0833333-.66666667-.0833333-.33333333 0-.66666667.0833333-.91666667.0833333-.16666666 0-.33333333.0833333-.41666666.0833333-.33333334 0-.5-.25-.5-.4166666 0-.1666667-.08333334-.25-.08333334-.4166667 0-.0833333-.08333333-.25-.08333333-.3333333-1.33333333-.25-2.08333333-.5-2.25-1-.08333333 0-.08333333-.0833334-.08333333-.1666667 0-.25.16666666-.4166667.41666666-.5 2.25-.3333333 3.33333334-2.75 3.33333334-2.8333333.08333333-.25.16666666-.4166667.08333333-.5833334-.08333333-.1666666-.58333333-.3333333-.91666667-.4166666-.08333333 0-.16666666-.0833334-.25-.0833334-.83333333-.3333333-1-.75-.91666666-1.0833333.08333333-.5.83333333-.75 1.25-.5833333.33333333.1666666.58333333.1666666.75.1666666h.25c0-.0833333 0-.25 0-.3333333-.08333334-1.16666667-.25-2.66666667.16666666-3.5 1.16666667-2.66666667 3.66666667-2.83333333 4.33333337-2.83333333h.3333333c.75 0 3.1666667.16666666 4.3333333 2.83333333.4166667.91666667.3333334 2.33333333.25 3.5v.0833333.25h.1666667c.1666667 0 .4166667-.0833333.6666667-.1666666.1666666-.0833334.25-.0833334.4166666-.0833334.1666667 0 .25 0 .4166667.0833334.3333333.0833333.5833333.4166666.5833333.6666666 0 .4166667-.3333333.6666667-.9166666.9166667-.0833334 0-.1666667.0833333-.25.0833333-.3333334.0833334-.8333334.25-.9166667.5-.0833333.1666667 0 .3333334.0833333.5.0833334.25 1.0833334 2.5 3.25 2.8333334.25 0 .4166667.25.4166667.5 0 .0833333 0 .1666666-.0833333.1666666-.1666667.4166667-.9166667.75-2.25 1 0 .0833334-.0833334.25-.0833334.3333334 0 .1666666-.0833333.25-.0833333.4166666-.0833333.25-.25.4166667-.5.4166667-.0833333 0-.25 0-.4166667-.0833333-.25-.0833334-.5-.0833334-.9166666-.0833334-.1666667 0-.4166667 0-.6666667.0833334-.4166667.0833333-.75.3333333-1.1666667.5833333-.6666666.5833333-1.5.8333333-2.5.8333333"
@@ -122,69 +119,78 @@
<nav class="mc--menu mc--menu-footer" aria-label="UMass Amherst Footer Menu"> <nav class="mc--menu mc--menu-footer" aria-label="UMass Amherst Footer Menu">
<ul class="menu m--menu m--menu-footer"> <ul class="menu m--menu m--menu-footer">
<li class="menu-item menu-item--expanded"> <li class="menu-item menu-item--expanded">
<span class="navigation-title">Thông tin</span> <span class="navigation-title">Info for...</span>
<div class="submenu-wrapper"> <div class="submenu-wrapper">
<ul class="submenu"> <ul class="submenu">
<li class="menu-item"> <li class="menu-item">
<a href="#">Bệnh nhân</a> <a href="https://www.umass.edu/gateway/info/prospective-students"
data-drupal-link-system-path="node/42831">Prospective Students</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="#">Người nhà bệnh nhân</a> <a href="https://www.umass.edu/gateway/info/current-students"
title="Click here for info for current students"
data-drupal-link-system-path="node/61">Current Students</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="#">Bác sĩ / Chuyên gia y tế</a> <a href="https://www.umass.edu/gateway/info/faculty-and-staff"
title="Click here for info for faculty and staff"
data-drupal-link-system-path="node/66">Faculty and Staff</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="#">Khách hàng Doanh nghiệp</a> <a href="https://www.umass.edu/gateway/info/parents-and-families"
title="Click here for info for parents and families"
data-drupal-link-system-path="node/81">Parents and Families</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="#">Đối tác</a> <a href="https://www.umass.edu/hr/careers" title="Info for Job Seekers">Job Seekers</a>
</li> </li>
</ul> </ul>
</div> </div>
</li> </li>
<li class="menu-item menu-item--expanded"> <li class="menu-item menu-item--expanded">
<span class="navigation-title">Thông tin</span> <span class="navigation-title">Resources</span>
<div class="submenu-wrapper"> <div class="submenu-wrapper">
<ul class="submenu"> <ul class="submenu">
<li class="menu-item"> <li class="menu-item">
<a href="#">Lịch khám bệnh</a> <a href="https://www.umass.edu/registrar/academic-calendar">Academic Calendar</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="#">Sơ đồ bệnh viện</a> <a href="https://www.umass.edu/admissions/visit/visitor-info">Campus Maps</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="#">Tìm bác sĩ</a> <a href="https://www.umass.edu/peoplefinder/">People Finder</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="#">Chính sách & Quy định</a> <a href="https://www.umass.edu/policy/">Policies</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="#">Chuyên khoa & Dịch vụ</a> <a href="https://www.umass.edu/gateway/atoz" title="A-Z Directory"
data-drupal-link-system-path="node/3931">Sites A-Z Directory</a>
</li> </li>
</ul> </ul>
</div> </div>
</li> </li>
<li class="menu-item menu-item--expanded"> <li class="menu-item menu-item--expanded">
<span class="navigation-title">Kết nối</span> <span class="navigation-title">Connect</span>
<div class="submenu-wrapper"> <div class="submenu-wrapper">
<ul class="submenu"> <ul class="submenu">
<li class="menu-item"> <li class="menu-item">
<a href="#">Tuyển dụng</a> <a href="https://www.umassalumni.com/">Alumni</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="#">Tin tức & Sự kiện</a> <a href="https://umassathletics.com/">Athletics</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="#">Hoạt động xã hội</a> <a href="https://www.uma-foundation.org/">Giving</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="#">Thư viện y học</a> <a href="https://www.library.umass.edu/">Libraries</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="#">Liên hệ</a> <a href="https://www.umass.edu/gateway/contact-us"
data-drupal-link-system-path="node/256">Contact Us</a>
</li> </li>
</ul> </ul>
</div> </div>
@@ -218,23 +224,23 @@
<nav class="mc--menu mc--menu-footer-utility" aria-label="UMass Amherst Footer Utility Menu"> <nav class="mc--menu mc--menu-footer-utility" aria-label="UMass Amherst Footer Utility Menu">
<ul class="menu m--menu m--menu-footer-utility"> <ul class="menu m--menu m--menu-footer-utility">
<li class="menu-item"> <li class="menu-item">
<a href="https://sisvietnam.vn/gateway/site-policies" <a href="https://www.umass.edu/gateway/site-policies"
data-drupal-link-system-path="node/3861">Site Policies</a> data-drupal-link-system-path="node/3861">Site Policies</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="https://sisvietnam.vn/policy/information-privacy-policy" <a href="https://www.umass.edu/policy/information-privacy-policy"
title="UMass Amherst privacy policy">Privacy</a> title="UMass Amherst privacy policy">Privacy</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="https://sisvietnam.vn/policy/affirmative-action-non-discrimination-and-title-ix-non-discrimination-policy" <a href="https://www.umass.edu/policy/affirmative-action-non-discrimination-and-title-ix-non-discrimination-policy"
title="UMass Amherst non-discrimination policies">Non-Discrimination Notice</a> title="UMass Amherst non-discrimination policies">Non-Discrimination Notice</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="https://sisvietnam.vn/accessibility/" <a href="https://www.umass.edu/accessibility/"
title="UMass Amherst Accessibility Resources">Accessibility</a> title="UMass Amherst Accessibility Resources">Accessibility</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="https://sisvietnam.vn/it/policies/it-policy-acceptable-use-interpretation-guidelines" <a href="https://www.umass.edu/it/policies/it-policy-acceptable-use-interpretation-guidelines"
title="UMass Amherst terms of use">Terms of Use</a> title="UMass Amherst terms of use">Terms of Use</a>
</li> </li>
</ul> </ul>
File diff suppressed because it is too large Load Diff
@@ -16,18 +16,29 @@
<!-- Add base CSS here if available --> <!-- Add base CSS here if available -->
<th:block layout:fragment="head"></th:block> <th:block layout:fragment="head"></th:block>
<!-- UMASS Styles -->
<link rel="stylesheet" th:href="@{/css/umass.css}">
<!-- Custom CSS (Highest Priority) --> <!-- Custom CSS (Highest Priority) -->
<link rel="stylesheet" th:href="@{/css/custom.css}"> <link rel="stylesheet" th:href="@{/css/custom.css}">
</head> </head>
<body> <body>
<header th:replace="~{fragments/header :: #l--main-header}"></header> <!-- Skip to main content link (Accessibility) -->
<a href="#main-content" class="visually-hidden focusable skip-link">Skip to main content</a>
<!-- Header Fragment -->
<div th:replace="~{fragments/header}"></div>
<!-- Main Content Block -->
<main id="main-content" role="main">
<div layout:fragment="content"> <div layout:fragment="content">
<!-- Nội dung của các trang con sẽ được chèn vào đây --> <!-- All elements will be defined in the editor page -->
</div> </div>
</main>
<footer th:replace="~{fragments/footer :: footer}"></footer> <!-- Footer Fragment -->
<div th:replace="~{fragments/footer}"></div>
</body> </body>
</html> </html>
@@ -16,6 +16,9 @@
<!-- Custom styles for this template--> <!-- 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"> <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> </head>
<body id="page-top"> <body id="page-top">
@@ -47,6 +50,48 @@
<!-- Divider --> <!-- Divider -->
<hr class="sidebar-divider"> <hr class="sidebar-divider">
<!-- Heading -->
<div class="sidebar-heading">
Content
</div>
<!-- Nav Item - Posts Collapse Menu -->
<li class="nav-item">
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#collapsePosts"
aria-expanded="true" aria-controls="collapsePosts">
<i class="fas fa-fw fa-newspaper"></i>
<span>Posts</span>
</a>
<div id="collapsePosts" class="collapse" aria-labelledby="headingPosts" data-parent="#accordionSidebar">
<div class="bg-white py-2 collapse-inner rounded">
<h6 class="collapse-header">Post Management:</h6>
<a class="collapse-item" th:href="@{/manage/posts}">All Posts</a>
<a class="collapse-item" th:href="@{/manage/posts/new}">Add New</a>
<a class="collapse-item" th:href="@{/manage/posts/categories}">Categories</a>
<a class="collapse-item" th:href="@{/manage/posts/tags}">Tags</a>
</div>
</div>
</li>
<!-- 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 --> <!-- Heading -->
<div class="sidebar-heading"> <div class="sidebar-heading">
Interface Interface
@@ -184,6 +229,9 @@
<!-- Custom scripts for all pages--> <!-- 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> <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> </body>
</html> </html>
@@ -64,7 +64,8 @@
font-family: 'Open Sans', Arial, Helvetica, sans-serif !important; font-family: 'Open Sans', Arial, Helvetica, sans-serif !important;
} }
#block-footerbranding img { #block-footerbranding,
#l--main-header img {
filter: drop-shadow(2px 0px 0 rgba(255, 255, 255, 1)) drop-shadow(0px 2px 0 rgba(255, 255, 255, 1)) drop-shadow(-2px 0px 0 rgba(255, 255, 255, 1)) drop-shadow(0px -2px 0 rgba(255, 255, 255, 1)); filter: drop-shadow(2px 0px 0 rgba(255, 255, 255, 1)) drop-shadow(0px 2px 0 rgba(255, 255, 255, 1)) drop-shadow(-2px 0px 0 rgba(255, 255, 255, 1)) drop-shadow(0px -2px 0 rgba(255, 255, 255, 1));
transition: filter 0.3s ease, transform 0.3s ease; transition: filter 0.3s ease, transform 0.3s ease;
} }
@@ -666,7 +667,7 @@
<div data-component-id="umass_base:video-embed" class="f--field f--video-embed"> <div data-component-id="umass_base:video-embed" class="f--field f--video-embed">
<iframe title="Video Content" id="video-132086-1" width="900" height="1600" <iframe title="Video Content" id="video-132086-1" width="900" height="1600"
src="./UMass Amherst _ UMass Amherst_files/q0ZxZVbnMqs.html" frameborder="0" src="https://www.youtube.com/embed/q0ZxZVbnMqs" frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen=""></iframe> allowfullscreen=""></iframe>
</div> </div>
@@ -724,7 +725,7 @@
<div data-component-id="umass_base:video-embed" class="f--field f--video-embed"> <div data-component-id="umass_base:video-embed" class="f--field f--video-embed">
<iframe title="Video Content" id="video-132086-2" width="900" height="1600" <iframe title="Video Content" id="video-132086-2" width="900" height="1600"
src="./UMass Amherst _ UMass Amherst_files/7D_TnBztQhU.html" frameborder="0" src="https://www.youtube.com/embed/7D_TnBztQhU" frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen=""></iframe> allowfullscreen=""></iframe>
</div> </div>
@@ -782,7 +783,7 @@
<div data-component-id="umass_base:video-embed" class="f--field f--video-embed"> <div data-component-id="umass_base:video-embed" class="f--field f--video-embed">
<iframe title="Video Content" id="video-132086-3" width="900" height="1600" <iframe title="Video Content" id="video-132086-3" width="900" height="1600"
src="./UMass Amherst _ UMass Amherst_files/YRzZ9aRZ69w.html" frameborder="0" src="https://www.youtube.com/embed/YRzZ9aRZ69w" frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen=""></iframe> allowfullscreen=""></iframe>
</div> </div>
@@ -841,7 +842,7 @@
<div data-component-id="umass_base:video-embed" class="f--field f--video-embed"> <div data-component-id="umass_base:video-embed" class="f--field f--video-embed">
<iframe title="Video Content" id="video-132086-4" width="900" height="1600" <iframe title="Video Content" id="video-132086-4" width="900" height="1600"
src="./UMass Amherst _ UMass Amherst_files/agqIJlJpbAY.html" frameborder="0" src="https://www.youtube.com/embed/agqIJlJpbAY" frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen=""></iframe> allowfullscreen=""></iframe>
</div> </div>
@@ -0,0 +1,318 @@
<!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">
<!-- Page Type -->
<div class="col-md-4">
<div class="form-group">
<label for="pageType" class="font-weight-bold">Page Type <span
class="text-danger">*</span></label>
<select class="form-control" id="pageType" th:field="*{pageType}">
<option th:each="pt : ${pageTypes}" th:value="${pt}" th:text="${pt}"></option>
</select>
</div>
</div>
<!-- Status -->
<div class="col-md-4">
<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-4">
<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 (&#9776;) 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">&times;</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">&times;</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} ? ${#dates.format(page.lastModifiedDateAsDate, '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,175 @@
<!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>Categories</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">Categories</h1>
<a th:href="@{/manage/posts}" 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 Posts
</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">&times;</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">&times;</span>
</button>
</div>
<div class="row">
<!-- Left Column: Add/Edit Category Form -->
<div class="col-lg-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary"
th:text="${isNew} ? 'Add New Category' : 'Edit Category'">Form</h6>
</div>
<div class="card-body">
<form th:action="${isNew} ? @{/manage/posts/categories} : @{/manage/posts/categories/{id}(id=${category.id})}"
th:object="${category}" method="post">
<!-- Validation errors -->
<div th:if="${#fields.hasErrors('*')}" class="alert alert-danger alert-sm">
<ul class="mb-0 small">
<li th:each="err : ${#fields.errors('*')}" th:text="${err}"></li>
</ul>
</div>
<!-- Name -->
<div class="form-group">
<label for="categoryName" class="font-weight-bold">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="categoryName" th:field="*{name}"
th:classappend="${#fields.hasErrors('name')} ? 'is-invalid' : ''"
placeholder="e.g. News, Events, Health Tips" required>
<div class="invalid-feedback" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></div>
</div>
<!-- Slug -->
<div class="form-group">
<label for="categorySlug" class="font-weight-bold">Slug</label>
<input type="text" class="form-control" id="categorySlug" th:field="*{slug}"
placeholder="auto-generated-from-name">
<small class="form-text text-muted">Leave blank to auto-generate.</small>
</div>
<!-- Parent Category -->
<div class="form-group">
<label for="parentCategory" class="font-weight-bold">Parent Category</label>
<select class="form-control" id="parentCategory" name="parentId">
<option value="">— None (Top Level) —</option>
<option th:each="cat : ${allCategories}" th:value="${cat.id}" th:text="${cat.name}"
th:if="${category.id == null || cat.id != category.id}"
th:selected="${category.parent != null && category.parent.id == cat.id}"></option>
</select>
</div>
<!-- Description -->
<div class="form-group">
<label for="categoryDescription" class="font-weight-bold">Description</label>
<textarea class="form-control" id="categoryDescription" th:field="*{description}" rows="3"
maxlength="1000"
placeholder="Optional description of this category"></textarea>
</div>
<!-- Buttons -->
<div class="d-flex justify-content-between">
<a th:if="${!isNew}" th:href="@{/manage/posts/categories}" class="btn btn-secondary btn-sm">
<i class="fas fa-times"></i> Cancel
</a>
<button type="submit" class="btn btn-primary btn-sm"
th:classappend="${isNew} ? 'btn-block' : ''">
<i class="fas fa-save"></i>
<span th:text="${isNew} ? 'Add New Category' : 'Update Category'">Save</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Right Column: Categories Table -->
<div class="col-lg-8">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Existing Categories</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-hover" id="categoriesTable" width="100%" cellspacing="0">
<thead class="thead-light">
<tr>
<th width="25%">Name</th>
<th width="20%">Slug</th>
<th width="25%">Description</th>
<th width="10%">Parent</th>
<th width="8%">Posts</th>
<th width="12%">Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="cat : ${allCategories}">
<td>
<strong th:text="${cat.name}"></strong>
</td>
<td>
<code th:text="${cat.slug}"></code>
</td>
<td>
<small th:text="${cat.description}" class="text-muted"></small>
<span th:if="${cat.description == null || cat.description.isEmpty()}" class="text-muted"></span>
</td>
<td>
<span th:if="${cat.parent != null}" th:text="${cat.parent.name}" class="badge badge-light"></span>
<span th:if="${cat.parent == null}" class="text-muted"></span>
</td>
<td class="text-center">
<span class="badge badge-primary" th:text="${postCounts.get(cat.id) ?: 0}"></span>
</td>
<td class="text-center">
<a th:href="@{/manage/posts/categories/{id}/edit(id=${cat.id})}"
class="btn btn-sm btn-outline-info mr-1" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form th:action="@{/manage/posts/categories/{id}/delete(id=${cat.id})}" method="post"
style="display:inline;"
onsubmit="return confirm('Are you sure you want to delete this category?');">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(allCategories)}">
<td colspan="6" class="text-center text-muted py-4">
<i class="fas fa-folder-open fa-2x mb-2 d-block"></i>
No categories yet. Use the form on the left to create one.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,479 @@
<!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 Post' : 'Edit Post'">Post 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;
}
.codex-editor__redactor {
padding-bottom: 80px !important;
}
.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;
}
/* Tag input styling */
.tag-input-container {
position: relative;
}
.tag-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
background: #fff;
border: 1px solid #d1d3e2;
border-top: none;
border-radius: 0 0 0.35rem 0.35rem;
max-height: 200px;
overflow-y: auto;
display: none;
}
.tag-suggestions .suggestion-item {
padding: 8px 12px;
cursor: pointer;
transition: background 0.15s;
}
.tag-suggestions .suggestion-item:hover {
background: #eaecf4;
}
/* Sidebar panels styling */
.publish-panel .card-body {
padding: 1rem;
}
.publish-panel .form-group {
margin-bottom: 0.75rem;
}
.publish-panel .form-group:last-child {
margin-bottom: 0;
}
/* Featured image preview */
.featured-image-preview {
max-width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: 0.35rem;
border: 1px solid #d1d3e2;
margin-top: 0.5rem;
display: none;
}
.featured-image-preview.has-image {
display: block;
}
</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 Post' : 'Edit Post'">Post Form</h1>
<a th:href="@{/manage/posts}" 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 Posts
</a>
</div>
<form id="postForm" th:action="${isNew} ? @{/manage/posts/create} : @{/manage/posts/{id}(id=${post.id})}"
th:object="${post}" 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>
<div class="row">
<!-- Main Content Column (Left) -->
<div class="col-lg-8">
<!-- Title Card -->
<div class="card shadow mb-4">
<div class="card-body">
<div class="form-group mb-0">
<input type="text" class="form-control form-control-lg" id="postTitle" th:field="*{title}"
th:classappend="${#fields.hasErrors('title')} ? 'is-invalid' : ''"
placeholder="Enter post title..." required
style="font-size: 1.4rem; font-weight: 600; border: none; padding: 0; box-shadow: none;">
<div class="invalid-feedback" th:if="${#fields.hasErrors('title')}" th:errors="*{title}"></div>
</div>
<!-- Slug -->
<div class="mt-2">
<small class="text-muted">
<i class="fas fa-link"></i> Permalink:
<code>/post/<span id="slugPreview" th:text="${post.slug ?: 'auto-generated'}"></span></code>
</small>
<input type="hidden" id="postSlug" th:field="*{slug}">
</div>
</div>
</div>
<!-- Block Editor Content -->
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-cubes"></i> Post Content (Block Editor)
</h6>
<button type="button" class="btn btn-sm btn-outline-primary" id="toggleFullscreenBtn">
<i class="fas fa-expand"></i> Full Screen
</button>
</div>
<div class="card-body">
<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.
</small>
</div>
</div>
<!-- Excerpt -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-align-left"></i> Excerpt
</h6>
</div>
<div class="card-body">
<textarea class="form-control" id="postExcerpt" th:field="*{excerpt}" rows="3"
maxlength="1000"
placeholder="Write a short summary of the post (optional, used for preview cards)..."></textarea>
<small class="form-text text-muted">A brief hand-written summary. If left blank, a generated snippet from the content will be used.</small>
</div>
</div>
<!-- SEO Meta Description -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-search"></i> SEO Settings
</h6>
</div>
<div class="card-body">
<div class="form-group mb-0">
<label for="postMetaDescription" class="font-weight-bold">Meta Description</label>
<textarea class="form-control" id="postMetaDescription" 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 post title.</small>
</div>
</div>
</div>
</div>
<!-- Sidebar Column (Right) -->
<div class="col-lg-4">
<!-- Publish Panel -->
<div class="card shadow mb-4 publish-panel">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-paper-plane"></i> Publish
</h6>
</div>
<div class="card-body">
<div class="form-group">
<label for="postStatus" class="font-weight-bold">Status</label>
<select class="form-control" id="postStatus" th:field="*{status}">
<option th:each="s : ${statuses}" th:value="${s}" th:text="${s}"></option>
</select>
</div>
<div class="form-group mt-3">
<label for="postLayout" class="font-weight-bold">Layout</label>
<select class="form-control" id="postLayout" th:field="*{layout}">
<option th:each="l : ${layouts}" th:value="${l}" th:text="${l}"></option>
</select>
<small class="form-text text-muted">Controls the visual appearance on the frontend.</small>
</div>
<hr>
<div class="d-flex justify-content-between">
<a th:href="@{/manage/posts}" class="btn btn-secondary btn-sm">
<i class="fas fa-times"></i> Cancel
</a>
<button type="submit" class="btn btn-primary btn-sm">
<i class="fas fa-save"></i>
<span th:text="${isNew} ? 'Publish' : 'Update'">Save</span>
</button>
</div>
</div>
</div>
<!-- Category Panel -->
<div class="card shadow mb-4 publish-panel">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-folder"></i> Category
</h6>
</div>
<div class="card-body">
<select class="form-control" id="postCategory" name="categoryId">
<option value="">— No Category —</option>
<option th:each="cat : ${categories}" th:value="${cat.id}" th:text="${cat.name}"
th:selected="${post.category != null && post.category.id == cat.id}"></option>
</select>
<small class="form-text text-muted mt-1">
<a th:href="@{/manage/posts/categories}" target="_blank">
<i class="fas fa-plus-circle"></i> Manage Categories
</a>
</small>
</div>
</div>
<!-- Tags Panel -->
<div class="card shadow mb-4 publish-panel">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-tags"></i> Tags
</h6>
</div>
<div class="card-body">
<div class="tag-input-container">
<input type="text" class="form-control" id="tagNamesInput" name="tagNames"
th:value="${tagNames}"
placeholder="e.g. news, health, update">
<div class="tag-suggestions" id="tagSuggestions"></div>
</div>
<small class="form-text text-muted mt-1">
Separate tags with commas. New tags will be created automatically.
</small>
<div class="mt-2" id="existingTags">
<span th:each="tag : ${allTags}" class="badge badge-light mr-1 mb-1"
style="cursor: pointer;" th:text="${tag.name}"
th:attr="data-tag-name=${tag.name}"
onclick="addTagToInput(this.getAttribute('data-tag-name'))"></span>
</div>
<small class="form-text text-muted">Click a tag to add it.</small>
</div>
</div>
<!-- Featured Image Panel -->
<div class="card shadow mb-4 publish-panel">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-image"></i> Featured Image
</h6>
</div>
<div class="card-body">
<input type="text" class="form-control" id="postFeaturedImage" th:field="*{featuredImage}"
placeholder="Image URL (e.g. /uploads/image.jpg)">
<img id="featuredImagePreview" class="featured-image-preview mt-2"
th:src="${post.featuredImage}" th:classappend="${post.featuredImage != null && !post.featuredImage.isEmpty()} ? 'has-image' : ''"
alt="Featured image preview">
<small class="form-text text-muted mt-1">Enter the URL of the featured image for this post.</small>
</div>
</div>
</div>
</div>
</form>
</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>
<!-- 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 and page-specific scripts -->
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
// --- Editor.js Initialization ---
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);
});
}
// --- Unsaved Changes Warning ---
var isFormSubmitted = false;
document.getElementById('postForm').addEventListener('submit', function () {
isFormSubmitted = true;
});
window.addEventListener('beforeunload', function (e) {
if (!isFormSubmitted) {
var msg = 'You may have unsaved changes. Are you sure you want to leave?';
e.returnValue = msg;
return msg;
}
});
// --- 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';
}
});
// --- Featured Image Preview ---
var imgInput = document.getElementById('postFeaturedImage');
var imgPreview = document.getElementById('featuredImagePreview');
imgInput.addEventListener('input', function () {
if (imgInput.value && imgInput.value.trim() !== '') {
imgPreview.src = imgInput.value;
imgPreview.classList.add('has-image');
} else {
imgPreview.classList.remove('has-image');
}
});
// --- Slug Auto-generation from Title ---
var titleInput = document.getElementById('postTitle');
var slugInput = document.getElementById('postSlug');
var slugPreview = document.getElementById('slugPreview');
var isSlugManuallySet = slugInput.value && slugInput.value.trim() !== '';
titleInput.addEventListener('input', function () {
if (!isSlugManuallySet) {
var slug = titleInput.value
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/đ/g, 'd').replace(/Đ/g, 'D')
.replace(/[^\w\s-]/g, '')
.replace(/[\s]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-|-$/g, '');
slugPreview.textContent = slug || 'auto-generated';
}
});
});
// --- Tag click-to-add helper ---
function addTagToInput(tagName) {
var input = document.getElementById('tagNamesInput');
var currentTags = input.value.split(',').map(function(t) { return t.trim(); }).filter(function(t) { return t !== ''; });
if (currentTags.indexOf(tagName) === -1) {
currentTags.push(tagName);
input.value = currentTags.join(', ');
}
}
</script>
</section>
</body>
</html>
@@ -0,0 +1,152 @@
<!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 Posts</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 Posts</h1>
<a th:href="@{/manage/posts/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 Post
</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">&times;</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">&times;</span>
</button>
</div>
<!-- Filter Bar -->
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-filter"></i> Filter Posts
</h6>
</div>
<div class="card-body">
<form th:action="@{/manage/posts}" method="get" class="form-inline">
<div class="form-group mr-3 mb-2">
<label for="filterCategory" class="mr-2 font-weight-bold">Category:</label>
<select class="form-control form-control-sm" id="filterCategory" name="categoryId">
<option value="">All Categories</option>
<option th:each="cat : ${categories}" th:value="${cat.id}" th:text="${cat.name}"
th:selected="${selectedCategoryId != null && selectedCategoryId == cat.id}"></option>
</select>
</div>
<div class="form-group mr-3 mb-2">
<label for="filterStatus" class="mr-2 font-weight-bold">Status:</label>
<select class="form-control form-control-sm" id="filterStatus" name="status">
<option value="">All Statuses</option>
<option th:each="s : ${statuses}" th:value="${s}" th:text="${s}"
th:selected="${selectedStatus != null && selectedStatus == s.name()}"></option>
</select>
</div>
<button type="submit" class="btn btn-sm btn-primary mb-2 mr-2">
<i class="fas fa-search"></i> Filter
</button>
<a th:href="@{/manage/posts}" class="btn btn-sm btn-outline-secondary mb-2">
<i class="fas fa-times"></i> Clear
</a>
</form>
</div>
</div>
<!-- Posts Table -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Manage Posts</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-hover" id="postsTable" width="100%" cellspacing="0">
<thead class="thead-light">
<tr>
<th width="5%">#</th>
<th width="28%">Title</th>
<th width="13%">Category</th>
<th width="17%">Tags</th>
<th width="8%">Status</th>
<th width="10%">Author</th>
<th width="12%">Date</th>
<th width="7%">Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="post, iterStat : ${posts}">
<td th:text="${iterStat.count}"></td>
<td>
<a th:href="@{/manage/posts/{id}/edit(id=${post.id})}" class="font-weight-bold text-primary"
th:text="${post.title}"></a>
<br>
<small class="text-muted">
<a th:href="@{/post/{slug}(slug=${post.slug})}" target="_blank" class="text-muted">
<i class="fas fa-external-link-alt"></i> /post/<span th:text="${post.slug}"></span>
</a>
</small>
</td>
<td>
<span th:if="${post.category != null}" th:text="${post.category.name}"
class="badge badge-light"></span>
<span th:if="${post.category == null}" class="text-muted"></span>
</td>
<td>
<span th:each="tag : ${post.tags}" class="badge badge-info mr-1 mb-1"
th:text="${tag.name}"></span>
<span th:if="${#sets.isEmpty(post.tags)}" class="text-muted"></span>
</td>
<td>
<span class="badge"
th:classappend="${post.status.name() == 'PUBLISHED'} ? 'badge-success' : (${post.status.name() == 'DRAFT'} ? 'badge-warning' : 'badge-secondary')"
th:text="${post.status}">
</span>
</td>
<td>
<small th:text="${post.createdBy}" class="text-muted"></small>
</td>
<td>
<small th:if="${post.createdDateAsDate != null}" th:text="${#dates.format(post.createdDateAsDate, 'yyyy-MM-dd HH:mm')}"></small>
<small th:if="${post.createdDateAsDate == null}">-</small>
</td>
<td class="text-center">
<a th:href="@{/manage/posts/{id}/edit(id=${post.id})}" class="btn btn-sm btn-outline-info mr-1" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form th:action="@{/manage/posts/{id}/delete(id=${post.id})}" method="post" style="display:inline;"
onsubmit="return confirm('Are you sure you want to delete this post?');">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(posts)}">
<td colspan="8" class="text-center text-muted py-4">
<i class="fas fa-newspaper fa-2x mb-2 d-block"></i>
No posts found. Click "Add New Post" to create one.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,159 @@
<!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>Tags</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">Tags</h1>
<a th:href="@{/manage/posts}" 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 Posts
</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">&times;</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">&times;</span>
</button>
</div>
<div class="row">
<!-- Left Column: Add/Edit Tag Form -->
<div class="col-lg-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary"
th:text="${isNew} ? 'Add New Tag' : 'Edit Tag'">Form</h6>
</div>
<div class="card-body">
<form th:action="${isNew} ? @{/manage/posts/tags} : @{/manage/posts/tags/{id}(id=${tag.id})}"
th:object="${tag}" method="post">
<!-- Validation errors -->
<div th:if="${#fields.hasErrors('*')}" class="alert alert-danger alert-sm">
<ul class="mb-0 small">
<li th:each="err : ${#fields.errors('*')}" th:text="${err}"></li>
</ul>
</div>
<!-- Name -->
<div class="form-group">
<label for="tagName" class="font-weight-bold">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="tagName" th:field="*{name}"
th:classappend="${#fields.hasErrors('name')} ? 'is-invalid' : ''"
placeholder="e.g. covid-19, health-tips, breaking-news" required>
<div class="invalid-feedback" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></div>
</div>
<!-- Slug -->
<div class="form-group">
<label for="tagSlug" class="font-weight-bold">Slug</label>
<input type="text" class="form-control" id="tagSlug" th:field="*{slug}"
placeholder="auto-generated-from-name">
<small class="form-text text-muted">Leave blank to auto-generate. Used in URLs for tag archive pages.</small>
</div>
<!-- Buttons -->
<div class="d-flex justify-content-between">
<a th:if="${!isNew}" th:href="@{/manage/posts/tags}" class="btn btn-secondary btn-sm">
<i class="fas fa-times"></i> Cancel
</a>
<button type="submit" class="btn btn-primary btn-sm"
th:classappend="${isNew} ? 'btn-block' : ''">
<i class="fas fa-save"></i>
<span th:text="${isNew} ? 'Add New Tag' : 'Update Tag'">Save</span>
</button>
</div>
</form>
</div>
</div>
<!-- Tag Info Card -->
<div class="card shadow mb-4 border-left-info">
<div class="card-body">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">About Tags</div>
<p class="small text-muted mb-0">
Tags are keywords attached to posts for quick searchability.
Unlike categories, tags have no hierarchy. You can assign multiple tags to a single post.
Tags can also be auto-created when composing a post by typing them in the tag input field.
</p>
</div>
</div>
</div>
<!-- Right Column: Tags Table -->
<div class="col-lg-8">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">Existing Tags</h6>
<span class="badge badge-primary badge-pill" th:text="${#lists.size(allTags)} + ' total'"></span>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-hover" id="tagsTable" width="100%" cellspacing="0">
<thead class="thead-light">
<tr>
<th width="30%">Name</th>
<th width="30%">Slug</th>
<th width="15%">Posts</th>
<th width="25%">Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="t : ${allTags}">
<td>
<strong th:text="${t.name}"></strong>
</td>
<td>
<code th:text="${t.slug}"></code>
</td>
<td class="text-center">
<span class="badge badge-primary" th:text="${postCounts.get(t.id) ?: 0}"></span>
</td>
<td class="text-center">
<a th:href="@{/manage/posts/tags/{id}/edit(id=${t.id})}"
class="btn btn-sm btn-outline-info mr-1" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form th:action="@{/manage/posts/tags/{id}/delete(id=${t.id})}" method="post"
style="display:inline;"
onsubmit="return confirm('Are you sure you want to delete this tag?');">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(allTags)}">
<td colspan="4" class="text-center text-muted py-4">
<i class="fas fa-tags fa-2x mb-2 d-block"></i>
No tags yet. Use the form on the left to create one.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,87 @@
<!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>
<!-- Render Editor.js Blocks -->
<div layout:fragment="content">
<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}}" th:remove="tag"></div>
</th:block>
<!-- 2. Header Block -->
<th:block th:if="${block.type == 'header'}">
<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>
</th:block>
<!-- 3. Paragraph Block -->
<th:block th:if="${block.type == 'paragraph'}">
<p th:utext="${block.data.text}"></p>
</th:block>
<!-- 4. List Block -->
<th:block th:if="${block.type == 'list'}">
<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>
</th:block>
<!-- 5. Image Block -->
<th:block th:if="${block.type == 'image'}">
<img th:src="${block.data.file.url}" th:alt="${block.data.caption}" />
<p th:if="${block.data.caption}" th:utext="${block.data.caption}"></p>
</th:block>
<!-- 6. Quote Block -->
<th:block th:if="${block.type == 'quote'}">
<blockquote>
<p th:utext="${block.data.text}"></p>
<footer th:if="${block.data.caption}" th:utext="${block.data.caption}"></footer>
</blockquote>
</th:block>
<!-- 7. Delimiter Block -->
<th:block th:if="${block.type == 'delimiter'}">
<hr />
</th:block>
<!-- 8. Table Block -->
<th:block th:if="${block.type == 'table'}">
<table>
<tbody>
<tr th:each="row, rowStat : ${block.data.content}">
<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>
</th:block>
</th:block>
</div>
</body>
</html>
@@ -0,0 +1,96 @@
<!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="${post.title} + ' | UMass Amherst'">Post Title | UMass Amherst</title>
<meta name="description" th:content="${post.metaDescription ?: post.excerpt}">
</head>
<body>
<div layout:fragment="content">
<div id="block-umass-base-content" class="block block-system block-system-main-block tc--template-container tc--article">
<div class="t--template t--article">
<!-- Content Top (e.g. Hero Image & Title) -->
<div class="content-top">
<div class="lc--layout-container lc--full">
<div class="l--layout l--full">
<div class="lr--layout-region lr--main">
<div class="cc--component-container cc--hero-article">
<div class="c--component c--hero-article">
<div class="f--field f--image" th:if="${post.featuredImage != null and !post.featuredImage.isEmpty()}">
<img th:src="${post.featuredImage}" th:alt="${post.title}" style="width:100%; object-fit:cover;">
</div>
<h1 class="f--field f--title text-center" th:text="${post.title}">Post Title</h1>
<p class="f--field f--date text-center" th:if="${formattedDate != null}" th:text="${formattedDate}">Date</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Content Main (Full Width) -->
<div class="content-main">
<div class="lc--layout-container lc--full">
<div class="l--layout l--full">
<div class="lr--layout-region lr--main">
<section class="cc--component-container cc--wysiwyg">
<div class="c--component c--wysiwyg">
<div class="f--field f--wysiwyg editorjs-content" id="postContentRaw" style="display:none;" th:text="${post.content}"></div>
<div class="f--field f--wysiwyg" id="postContentParsed"></div>
</div>
</section>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
var rawData = document.getElementById("postContentRaw").textContent;
var parsedContainer = document.getElementById("postContentParsed");
try {
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
data.blocks.forEach(function(block) {
switch(block.type) {
case "header":
html += "<h" + block.data.level + ">" + block.data.text + "</h" + block.data.level + ">";
break;
case "paragraph":
html += "<p>" + block.data.text + "</p>";
break;
case "image":
html += "<figure><img src='" + block.data.url + "' alt='" + (block.data.caption || '') + "'><figcaption>" + (block.data.caption || '') + "</figcaption></figure>";
break;
case "list":
var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
html += "<" + tag + ">";
block.data.items.forEach(function(item) {
html += "<li>" + item + "</li>";
});
html += "</" + tag + ">";
break;
case "quote":
html += "<blockquote>" + block.data.text + " <cite>" + block.data.caption + "</cite></blockquote>";
break;
default:
console.log("Unknown block type", block.type);
}
});
}
parsedContainer.innerHTML = html;
} catch(e) {
parsedContainer.innerHTML = rawData;
}
});
</script>
</div>
</body>
</html>
@@ -0,0 +1,119 @@
<!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="${post.title} + ' | UMass Amherst'">Post Title | UMass Amherst</title>
<meta name="description" th:content="${post.metaDescription ?: post.excerpt}">
</head>
<body>
<div layout:fragment="content">
<div id="block-umass-base-content" class="block block-system block-system-main-block tc--template-container tc--article">
<div class="t--template t--article">
<!-- Content Top (e.g. Hero Image & Title) -->
<div class="content-top">
<div class="lc--layout-container lc--full">
<div class="l--layout l--full">
<div class="lr--layout-region lr--main">
<div class="cc--component-container cc--hero-article">
<div class="c--component c--hero-article">
<div class="f--field f--image" th:if="${post.featuredImage != null and !post.featuredImage.isEmpty()}">
<img th:src="${post.featuredImage}" th:alt="${post.title}">
</div>
<h1 class="f--field f--title" th:text="${post.title}">Post Title</h1>
<p class="f--field f--date" th:if="${formattedDate != null}" th:text="${formattedDate}">Date</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Content Main (Sidebar Layout) -->
<div class="content-main">
<div class="lc--layout-container lc--two-column">
<div class="l--layout l--two-column">
<!-- Sidebar -->
<aside class="lr--layout-region lr--sidebar">
<div class="cc--component-container cc--contact-block">
<div class="c--component c--contact-block">
<h3>About the Author</h3>
<p>This is a placeholder for author details or related links that appear in the sidebar.</p>
<div th:if="${post.category != null}">
<h4>Category</h4>
<p th:text="${post.category.name}">Category Name</p>
</div>
<div th:if="${!post.tags.isEmpty()}">
<h4>Tags</h4>
<ul>
<li th:each="tag : ${post.tags}" th:text="${tag.name}">Tag</li>
</ul>
</div>
</div>
</div>
</aside>
<!-- Main Content -->
<div class="lr--layout-region lr--main">
<section class="cc--component-container cc--wysiwyg">
<div class="c--component c--wysiwyg">
<div class="f--field f--wysiwyg editorjs-content" id="postContentRaw" style="display:none;" th:text="${post.content}"></div>
<div class="f--field f--wysiwyg" id="postContentParsed"></div>
</div>
</section>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
var rawData = document.getElementById("postContentRaw").textContent;
var parsedContainer = document.getElementById("postContentParsed");
try {
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
data.blocks.forEach(function(block) {
switch(block.type) {
case "header":
html += "<h" + block.data.level + ">" + block.data.text + "</h" + block.data.level + ">";
break;
case "paragraph":
html += "<p>" + block.data.text + "</p>";
break;
case "image":
html += "<figure><img src='" + block.data.url + "' alt='" + (block.data.caption || '') + "'><figcaption>" + (block.data.caption || '') + "</figcaption></figure>";
break;
case "list":
var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
html += "<" + tag + ">";
block.data.items.forEach(function(item) {
html += "<li>" + item + "</li>";
});
html += "</" + tag + ">";
break;
case "quote":
html += "<blockquote>" + block.data.text + " <cite>" + block.data.caption + "</cite></blockquote>";
break;
default:
console.log("Unknown block type", block.type);
}
});
}
parsedContainer.innerHTML = html;
} catch(e) {
parsedContainer.innerHTML = rawData;
}
});
</script>
</div>
</body>
</html>
@@ -0,0 +1,101 @@
<!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="${post.title} + ' | UMass Amherst'">Post Title | UMass Amherst</title>
<meta name="description" th:content="${post.metaDescription ?: post.excerpt}">
</head>
<body>
<div layout:fragment="content">
<div id="block-umass-base-content" class="block block-system block-system-main-block tc--template-container tc--article">
<div class="t--template t--article">
<!-- Content Top (e.g. Hero Image & Title) -->
<div class="content-top">
<div class="lc--layout-container lc--full">
<div class="l--layout l--full">
<div class="lr--layout-region lr--main">
<div class="cc--component-container cc--hero-article">
<div class="c--component c--hero-article">
<div class="f--field f--image" th:if="${post.featuredImage != null and !post.featuredImage.isEmpty()}">
<img th:src="${post.featuredImage}" th:alt="${post.title}">
</div>
<h1 class="f--field f--title" th:text="${post.title}">Post Title</h1>
<p class="f--field f--date" th:if="${formattedDate != null}" th:text="${formattedDate}">Date</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Content Main (Standard One Column) -->
<div class="content-main">
<div class="lc--layout-container lc--one-column">
<div class="l--layout l--one-column">
<div class="lr--layout-region lr--main">
<section class="cc--component-container cc--wysiwyg">
<div class="c--component c--wysiwyg">
<!-- Output raw HTML from block editor (we need to parse or render it). Since it's saved as raw HTML from the previous plugin or JSON, we just output it. Wait, the editor uses Editor.js, so the content might be JSON! -->
<!-- Assuming the content is parsed or we just dump it for now -->
<div class="f--field f--wysiwyg editorjs-content" id="postContentRaw" style="display:none;" th:text="${post.content}"></div>
<div class="f--field f--wysiwyg" id="postContentParsed"></div>
</div>
</section>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Include Editor.js parser to render the content on the frontend -->
<!-- Because Editor.js saves as JSON, we need to convert it to HTML. -->
<script>
document.addEventListener("DOMContentLoaded", function() {
var rawData = document.getElementById("postContentRaw").textContent;
var parsedContainer = document.getElementById("postContentParsed");
try {
var data = JSON.parse(rawData);
var html = "";
if (data.blocks) {
data.blocks.forEach(function(block) {
switch(block.type) {
case "header":
html += "<h" + block.data.level + ">" + block.data.text + "</h" + block.data.level + ">";
break;
case "paragraph":
html += "<p>" + block.data.text + "</p>";
break;
case "image":
html += "<figure><img src='" + block.data.url + "' alt='" + (block.data.caption || '') + "'><figcaption>" + (block.data.caption || '') + "</figcaption></figure>";
break;
case "list":
var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
html += "<" + tag + ">";
block.data.items.forEach(function(item) {
html += "<li>" + item + "</li>";
});
html += "</" + tag + ">";
break;
case "quote":
html += "<blockquote>" + block.data.text + " <cite>" + block.data.caption + "</cite></blockquote>";
break;
default:
console.log("Unknown block type", block.type);
}
});
}
parsedContainer.innerHTML = html;
} catch(e) {
// If it's not JSON, it might just be HTML
parsedContainer.innerHTML = rawData;
}
});
</script>
</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