From 1ee67e6a466bc206e62accf03c92bc80a7309962 Mon Sep 17 00:00:00 2001 From: Victor Phan Date: Sat, 1 Aug 2026 18:34:51 +0700 Subject: [PATCH] feat: implement custom CSS generator, improve code editor support, and add media picker and HTML tag management tools --- .../html_snippets/content_main_blocks/temp | 4014 +---------------- .../content_main_blocks/temp.css.css | 80 + .../content_main_blocks/temp.js.js | 11 + .../html_snippets/content_main_blocks/temp_2 | 30 - .../content_main_blocks/temp_2.html | 5 + .../src/main/resources/static/css/custom.css | 277 +- .../static/js/manage/editor-config.js | 114 +- .../static/js/manage/editor-plugins/flex.js | 10 +- .../static/js/manage/editor-plugins/grid.js | 10 +- .../js/manage/editor-plugins/hover-card.js | 428 ++ .../static/js/manage/editor-plugins/tag.js | 201 + .../static/js/manage/sis-media-picker.js | 208 + .../templates/manage/pages/form.html | 90 +- .../templates/manage/posts/form.html | 69 +- .../src/main/resources/templates/page.html | 82 +- .../resources/templates/posts/full-width.html | 5 +- .../resources/templates/posts/livestream.html | 43 +- .../resources/templates/posts/sidebar.html | 5 +- .../resources/templates/posts/standard.html | 5 +- sisvietnamvn_main/uploads/2026/08/core-1.webp | Bin 0 -> 6702 bytes sisvietnamvn_main/uploads/2026/08/core-2.webp | Bin 0 -> 6186 bytes sisvietnamvn_main/uploads/2026/08/core-3.webp | Bin 0 -> 7328 bytes sisvietnamvn_main/uploads/2026/08/core-4.webp | Bin 0 -> 4994 bytes .../uploads/2026/08/core-hover-1.webp | Bin 0 -> 3234 bytes .../uploads/2026/08/core-hover-2 (2).webp | Bin 0 -> 2540 bytes .../uploads/2026/08/core-hover-2.webp | Bin 0 -> 2540 bytes .../uploads/2026/08/core-hover-3.webp | Bin 0 -> 4026 bytes .../uploads/2026/08/core-hover-4.webp | Bin 0 -> 1990 bytes tam_nhin_su_menh_import.json | 203 +- 29 files changed, 1807 insertions(+), 4083 deletions(-) create mode 100644 sisvietnamvn_main/html_snippets/content_main_blocks/temp.css.css create mode 100644 sisvietnamvn_main/html_snippets/content_main_blocks/temp.js.js delete mode 100644 sisvietnamvn_main/html_snippets/content_main_blocks/temp_2 create mode 100644 sisvietnamvn_main/html_snippets/content_main_blocks/temp_2.html create mode 100644 sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/hover-card.js create mode 100644 sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tag.js create mode 100644 sisvietnamvn_main/src/main/resources/static/js/manage/sis-media-picker.js create mode 100644 sisvietnamvn_main/uploads/2026/08/core-1.webp create mode 100644 sisvietnamvn_main/uploads/2026/08/core-2.webp create mode 100644 sisvietnamvn_main/uploads/2026/08/core-3.webp create mode 100644 sisvietnamvn_main/uploads/2026/08/core-4.webp create mode 100644 sisvietnamvn_main/uploads/2026/08/core-hover-1.webp create mode 100644 sisvietnamvn_main/uploads/2026/08/core-hover-2 (2).webp create mode 100644 sisvietnamvn_main/uploads/2026/08/core-hover-2.webp create mode 100644 sisvietnamvn_main/uploads/2026/08/core-hover-3.webp create mode 100644 sisvietnamvn_main/uploads/2026/08/core-hover-4.webp diff --git a/sisvietnamvn_main/html_snippets/content_main_blocks/temp b/sisvietnamvn_main/html_snippets/content_main_blocks/temp index efdc90db..bdf4b0c8 100644 --- a/sisvietnamvn_main/html_snippets/content_main_blocks/temp +++ b/sisvietnamvn_main/html_snippets/content_main_blocks/temp @@ -1,3869 +1,155 @@ - -
- -
- - - - - -
-

Bệnh viện Đa Khoa Quốc Tế S.I.S Cần Thơ

-
-
-
-
- - Sisvietnamvn - -
-
- Bệnh viện đa khoa quốc tế
S.I.S Cần thơ -
-
-
-
-
-
-
- - Sisvietnamvn - -
-
-
-
- - -
- -
- -
- -
-
-
-
-
- - - - -
- -
-
-
-
- -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

Năm 2015

-

Hiệp hội Đột quỵ toàn cầu chuẩn hóa kỹ thuật lấy huyết khối cơ học, bộc lộ khoảng trống y tế tại ĐBSCL.

-
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

20/07/2017

-

Khởi công xây dựng dự án Bệnh viện trên quỹ đất 4.000m2 tại trung tâm TP. Cần Thơ.

-
-
-
- Lịch sử -
-
-
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

05/11/2018

-

Cất nóc và bàn giao công trình hạ tầng 10 tầng với diện tích sàn 13.000m2.

-
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

25/01/2019

-

Bộ Y tế chính thức cấp phép hoạt động dưới mô hình Bệnh viện Đa khoa Quốc tế.

-
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

20/02/2019

-

Tổ chức Lễ khánh thành chính thức và Bệnh viện Đa khoa Quốc tế S.I.S Cần Thơ đi vào vận hành khám chữa bệnh.

-
-
-
- Lịch sử -
-
-
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

06/07/2020

-

Bộ Nội vụ chính thức cấp phép thành lập Quỹ Từ thiện bệnh nhân nghèo bị đột quỵ khu vực Đồng bằng sông Cửu Long.

-
-
-
- Lịch sử -
-
-
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

19/02/2021

-

Thành lập Khoa Vật lý trị liệu – Phục hồi chức năng, hoàn thiện quy trình điều trị toàn diện sau đột quỵ.

-
-
-
- Lịch sử -
-
-
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

Tháng 10/2021

-

Tổ chức Đột quỵ Thế giới (WSO) chính thức trao chứng nhận tiêu chuẩn Bạch Kim (Platinum) cho Bệnh viện.

-
-
-
- Lịch sử -
-
-
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

Quý 3/2022

-

S.I.S Cần Thơ xuất sắc thăng hạng, đạt chuẩn Kim Cương (Diamond Status) của WSO - mức đánh giá cao nhất trên bản đồ đột quỵ toàn cầu.

-
-
-
- Lịch sử -
-
-
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

04/12/2023

-

Phòng khám Đa khoa Quốc tế S.I.S Đà Nẵng chính thức mở cửa phục vụ người dân, bước đầu mở rộng mạng lưới ra Miền Trung.

-
-
-
- Lịch sử -
-
-
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

18/07/2025

-

Ký kết thỏa thuận đối tác chiến lược với Siemens Healthineers tại Đức, chính thức trở thành Trung tâm Đào tạo Toàn cầu (Global Training Center).

-
-
-
- Lịch sử -
-
-
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

12/01/2026

-

Lễ động thổ triển khai Dự án Bệnh viện Đa khoa Quốc tế S.I.S Cần Thơ – Giai đoạn mở rộng với quy mô 300-500 giường bệnh nội trú.

-
-
-
- Lịch sử -
-
-
- -
- - - - - -
- - -
-
-
-
-
- - - - - - - - - - - - - - - - - - - \ No newline at end of file + ], + "version": "2.31.0-rc.7" +} \ No newline at end of file diff --git a/sisvietnamvn_main/html_snippets/content_main_blocks/temp.css.css b/sisvietnamvn_main/html_snippets/content_main_blocks/temp.css.css new file mode 100644 index 00000000..1ec0830a --- /dev/null +++ b/sisvietnamvn_main/html_snippets/content_main_blocks/temp.css.css @@ -0,0 +1,80 @@ +.sis-hover-card-grid { + padding-top: 30px; + padding-bottom: 30px; +} +.sis-hover-card-grid .row { + display: flex !important; + align-items: stretch !important; +} +.sis-hover-card-grid .row > [class*=\"col-\"] { + transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) !important; + cursor: pointer; + background-color: #ffffff !important; + border: 1px solid #e6f0fa !important; + border-radius: 12px !important; + box-shadow: 0 4px 15px rgba(0, 37, 84, 0.08) !important; + padding: 30px 20px !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: flex-start !important; + min-height: 260px; + text-align: center; + position: relative; + overflow: hidden; +} +.sis-hover-card-grid .row > [class*=\"col-\"] img { + height: 80px !important; + width: auto !important; + max-width: 100% !important; + object-fit: contain !important; + margin-bottom: 16px !important; + transition: + filter 0.4s ease, + transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) !important; +} +.sis-hover-card-grid .row > [class*=\"col-\"] h3 { + color: #0d71ba !important; + font-size: 1.25rem !important; + font-weight: 700 !important; + margin-bottom: 0 !important; + transition: color 0.4s ease !important; +} +.sis-hover-card-grid .row > [class*=\"col-\"] p { + color: rgba(255, 255, 255, 0) !important; + font-size: 0.9rem !important; + line-height: 1.5 !important; + margin-top: 0 !important; + margin-bottom: 0 !important; + max-height: 0; + opacity: 0; + overflow: hidden; + transition: + max-height 0.4s ease, + opacity 0.4s ease, + color 0.4s ease, + margin-top 0.4s ease !important; +} +.sis-hover-card-grid .row > [class*=\"col-\"]:hover, +.sis-hover-card-grid .row > [class*=\"col-\"].active { + background-color: #0d71ba !important; + border-color: #0d71ba !important; + transform: translateY(-8px) !important; + box-shadow: 0 12px 25px rgba(13, 113, 186, 0.3) !important; +} +.sis-hover-card-grid .row > [class*=\"col-\"]:hover h3, +.sis-hover-card-grid .row > [class*=\"col-\"].active h3 { + color: #ffffff !important; +} +.sis-hover-card-grid .row > [class*=\"col-\"]:hover p, +.sis-hover-card-grid .row > [class*=\"col-\"].active p { + color: #ffffff !important; + opacity: 1 !important; + max-height: 150px; + margin-top: 12px !important; +} +.sis-hover-card-grid .row > [class*=\"col-\"]:hover img, +.sis-hover-card-grid .row > [class*=\"col-\"].active img { + /* filter: brightness(0) invert(1) !important; */ + transform: scale(1.05) !important; +} diff --git a/sisvietnamvn_main/html_snippets/content_main_blocks/temp.js.js b/sisvietnamvn_main/html_snippets/content_main_blocks/temp.js.js new file mode 100644 index 00000000..8cc82570 --- /dev/null +++ b/sisvietnamvn_main/html_snippets/content_main_blocks/temp.js.js @@ -0,0 +1,11 @@ +document.addEventListener('DOMContentLoaded', function () { + var cards = document.querySelectorAll('.sis-hover-card-grid .row > [class*=\"col-\"]'); + if (cards.length > 0) { + cards.forEach(function (card) { + card.addEventListener('click', function () { + cards.forEach(function (c) { c.classList.remove('active'); }); + card.classList.add('active'); + }); + }); + } +}); diff --git a/sisvietnamvn_main/html_snippets/content_main_blocks/temp_2 b/sisvietnamvn_main/html_snippets/content_main_blocks/temp_2 deleted file mode 100644 index 43cf9688..00000000 --- a/sisvietnamvn_main/html_snippets/content_main_blocks/temp_2 +++ /dev/null @@ -1,30 +0,0 @@ -
-
- -
-
-
ts-bs_tran_chi_cuong_giam_doc_chuyen_mon_benh_vien_da_khoa_quoc_te_s-i-s_can_tho_nguoi_truc_tiep_thuc_hien_can_thiep.jpg
e-kip_se_vua_tien_hanh_can_thiep_vua_trao_doi_va_thao_luan_truc_tiep_voi_cac_chuyen_gia_tai_hoi_nghi.jpg
00001_backup.jpg
benh-vien-2-8570.jpg
-
-
- -
- - -
\ No newline at end of file diff --git a/sisvietnamvn_main/html_snippets/content_main_blocks/temp_2.html b/sisvietnamvn_main/html_snippets/content_main_blocks/temp_2.html new file mode 100644 index 00000000..57600292 --- /dev/null +++ b/sisvietnamvn_main/html_snippets/content_main_blocks/temp_2.html @@ -0,0 +1,5 @@ +
+
+

GIÁ TRỊ CỐT LÕI

+
+
diff --git a/sisvietnamvn_main/src/main/resources/static/css/custom.css b/sisvietnamvn_main/src/main/resources/static/css/custom.css index 68449765..18645f15 100644 --- a/sisvietnamvn_main/src/main/resources/static/css/custom.css +++ b/sisvietnamvn_main/src/main/resources/static/css/custom.css @@ -565,6 +565,10 @@ figure.table table tr:hover { text-decoration-line: none; } +.text-center { + text-align: center; +} + /* ========================================================================== 5. Responsive Breakpoints — 3 Groups Only ========================================================================== */ @@ -1370,7 +1374,7 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */ line-height: 1.8; margin-bottom: 0; } - + .sis-timeline__mobile-img { display: none; } @@ -1385,9 +1389,9 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */ top: 174px; padding-bottom: 4rem; } - + .sis-timeline__img-slot-empty { - visibility: hidden; + visibility: hidden; } .sis-timeline__img-wrapper { @@ -1455,3 +1459,270 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */ border-radius: 1rem; } } + +/* ========================================================================== + Dedicated Hover Cards Plugin Styles + ========================================================================== */ +.sis-hover-card-section { + padding-top: 30px; + padding-bottom: 30px; +} +.sis-hover-card { + background-color: #ffffff; + border: 1px solid #e6f0fa; + border-radius: 12px; + box-shadow: 0 4px 15px rgba(0, 37, 84, 0.06); + padding: 25px 0px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + cursor: pointer; + transition: all 0.35s cubic-bezier(0.25, 0.8, 0.25, 1); + position: relative; + overflow: hidden; +} +.sis-hover-card__icon { + margin-left: auto !important; + margin-right: auto !important; + margin-bottom: 18px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + height: 80px !important; + width: 80px !important; + position: relative !important; + flex-shrink: 0 !important; +} +.sis-hover-card__icon img { + height: 80px !important; + width: auto !important; + max-width: 100% !important; + object-fit: contain !important; + margin: 0 auto !important; +} +.sis-hover-card__img-main { + position: absolute !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + opacity: 1 !important; + display: block !important; + transition: all 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important; +} +.sis-hover-card__img-hover { + position: absolute !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + opacity: 0 !important; + pointer-events: none !important; + transition: all 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important; +} +.sis-hover-card__title { + color: var(--color-old-brick) !important; + font-size: 1.25rem !important; + font-weight: 700 !important; + margin-bottom: 0 !important; + transition: color 0.35s ease !important; +} +.sis-hover-card__desc { + color: rgba(255, 255, 255, 0) !important; + font-size: 0.9rem !important; + line-height: 1.55 !important; + margin-top: 0 !important; + margin-bottom: 0 !important; + max-height: 0; + opacity: 0; + overflow: hidden; + transition: + max-height 0.4s ease, + opacity 0.35s ease, + color 0.35s ease, + margin-top 0.35s ease !important; +} +.sis-hover-card:hover, +.sis-hover-card.active { + background-color: #0d71ba !important; + border-color: #0d71ba !important; + transform: translateY(-8px) !important; + box-shadow: 0 12px 28px rgba(13, 113, 186, 0.28) !important; +} +.sis-hover-card:hover .sis-hover-card__title, +.sis-hover-card.active .sis-hover-card__title { + color: #ffffff !important; +} +.sis-hover-card:hover .sis-hover-card__desc, +.sis-hover-card.active .sis-hover-card__desc { + color: #ffffff !important; + opacity: 1 !important; + max-height: 160px; + margin-top: 14px !important; +} +/* If hover image is present, swap images */ +.sis-hover-card:hover .sis-hover-card__img-main:has(~ .sis-hover-card__img-hover), +.sis-hover-card.active .sis-hover-card__img-main:has(~ .sis-hover-card__img-hover) { + opacity: 0 !important; +} +.sis-hover-card:hover .sis-hover-card__img-hover, +.sis-hover-card.active .sis-hover-card__img-hover { + opacity: 1 !important; + transform: translate(-50%, -50%) scale(1.3) !important; +} +/* Fallback image filter if hover image is NOT present */ +.sis-hover-card:hover .sis-hover-card__icon img:not(.sis-hover-card__img-hover):not(:has(~ .sis-hover-card__img-hover)), +.sis-hover-card.active .sis-hover-card__icon img:not(.sis-hover-card__img-hover):not(:has(~ .sis-hover-card__img-hover)) { + transform: translate(-50%, -50%) scale(1.3) !important; +} + +/* ========================================================================== + Hover Cards Variant 2 (Hover 2 — Modern Blue & Glow Style) + ========================================================================== */ +.sis-hover-card-section .row, +.sis-hover-card-v2-section .row { + align-items: center !important; +} + +.sis-hover-card-v2-section { + padding-top: 40px; + padding-bottom: 40px; +} + +.sis-hover-card-v2 { + border: 1px solid rgba(53, 157, 253, 0.25) !important; + border-radius: 16px !important; + padding: 32px 20px !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + text-align: center !important; + cursor: pointer !important; + position: relative !important; + overflow: hidden !important; + transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) !important; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18) !important; +} + +.sis-hover-card-v2 .sis-hover-card__icon { + margin-bottom: 18px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + height: 85px !important; + width: 85px !important; + position: relative !important; +} + +.sis-hover-card-v2 .sis-hover-card__icon img { + height: 80px !important; + width: auto !important; + max-width: 100% !important; + object-fit: contain !important; + transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) !important; +} + +.sis-hover-card-v2 .sis-hover-card__title { + -webkit-background-clip: text !important; + font-size: 1.3rem !important; + font-weight: 700 !important; + margin-bottom: 0 !important; + transition: all 0.35s ease !important; +} + +.sis-hover-card-v2 .sis-hover-card__desc { + color: rgba(255, 255, 255, 0.9) !important; + font-size: 0.92rem !important; + line-height: 1.6 !important; + margin-top: 0 !important; + margin-bottom: 0 !important; + max-height: 0; + opacity: 0; + overflow: hidden; + transition: + max-height 0.4s ease, + opacity 0.35s ease, + margin-top 0.35s ease !important; +} + +/* Hover 2 Active / Hover State */ +.sis-hover-card-v2:hover, +.sis-hover-card-v2.active { + background: linear-gradient(135deg, #0d71ba 0%, #003c7e 100%) !important; + border-color: #359dfd !important; + transform: translateY(-8px) scale(1.02) !important; + box-shadow: 0 16px 36px rgba(13, 113, 186, 0.45) !important; +} + +.sis-hover-card-v2:hover .sis-hover-card__title, +.sis-hover-card-v2.active .sis-hover-card__title { + -webkit-background-clip: unset !important; + -webkit-text-fill-color: #ffffff !important; + color: #ffffff !important; +} + +.sis-hover-card-v2:hover .sis-hover-card__desc, +.sis-hover-card-v2.active .sis-hover-card__desc { + opacity: 1 !important; + max-height: 180px !important; + margin-top: 14px !important; +} + +/* Hover 2 Image Swapping */ +.sis-hover-card-v2:hover .sis-hover-card__img-main:has(~ .sis-hover-card__img-hover), +.sis-hover-card-v2.active .sis-hover-card__img-main:has(~ .sis-hover-card__img-hover) { + opacity: 0 !important; +} + +.sis-hover-card-v2:hover .sis-hover-card__img-hover, +.sis-hover-card-v2.active .sis-hover-card__img-hover { + opacity: 1 !important; + transform: translate(-50%, -50%) scale(1.15) !important; +} + +.sis-hover-card-v2:hover .sis-hover-card__icon img:not(.sis-hover-card__img-hover):not(:has(~ .sis-hover-card__img-hover)), +.sis-hover-card-v2.active .sis-hover-card__icon img:not(.sis-hover-card__img-hover):not(:has(~ .sis-hover-card__img-hover)) { + filter: brightness(0) invert(1) !important; + transform: translate(-50%, -50%) scale(1.15) !important; +} + +/* Responsive Layout for Tablet & Mobile */ +@media (max-width: 991px) { + .sis-hover-card-v2-section { + padding-top: 25px; + padding-bottom: 25px; + } + .sis-hover-card-v2 { + min-height: 220px; + padding: 24px 16px !important; + } + .sis-hover-card-v2 .sis-hover-card__title { + font-size: 1.15rem !important; + } + .sis-hover-card-v2 .sis-hover-card__desc { + font-size: 0.85rem !important; + } +} + +@media (max-width: 575px) { + .sis-hover-card-v2-section { + padding-top: 15px; + padding-bottom: 15px; + } + .sis-hover-card-v2 { + min-height: 180px; + padding: 18px 10px !important; + } + .sis-hover-card-v2 .sis-hover-card__icon { + height: 65px !important; + width: 65px !important; + margin-bottom: 10px !important; + } + .sis-hover-card-v2 .sis-hover-card__icon img { + height: 60px !important; + } + .sis-hover-card-v2 .sis-hover-card__title { + font-size: 1.05rem !important; + } +} diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js index 4cf0694a..dcedaed5 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-config.js @@ -31,6 +31,53 @@ // Global plugin registry — external plugins register here window.SISEditorPlugins = window.SISEditorPlugins || {}; +/** + * Global Media Library Modal Handler + * Callable from any Editor.js plugin via window.openSISMediaModal(callback) + */ +window.openSISMediaModal = function (callback) { + if (window.SISMediaPicker && typeof window.SISMediaPicker.open === 'function') { + window.SISMediaPicker.open(callback); + } else { + window.sisMediaSelectCallback = callback; + var modalEl = document.getElementById('sisMediaLibraryModal'); + if (modalEl && typeof $ !== 'undefined' && $.fn && $.fn.modal) { + $(modalEl).modal('show'); + } + } +}; + +/** + * Dynamic Monaco Editor Script Loader Helper + */ +window.loadMonacoEditor = function () { + if (window.monaco) return Promise.resolve(window.monaco); + if (window._monacoLoadingPromise) return window._monacoLoadingPromise; + + window._monacoLoadingPromise = new Promise((resolve, reject) => { + if (typeof require !== 'undefined' && require.config) { + require(['vs/editor/editor.main'], function () { + resolve(window.monaco); + }); + return; + } + const script = document.createElement('script'); + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js'; + script.onload = () => { + window.require.config({ + paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' } + }); + window.require(['vs/editor/editor.main'], () => { + resolve(window.monaco); + }); + }; + script.onerror = (err) => reject(err); + document.head.appendChild(script); + }); + + return window._monacoLoadingPromise; +}; + class SISRawHtmlTool { static get toolbox() { return { @@ -47,11 +94,13 @@ class SISRawHtmlTool { stretched: !!(data && data.stretched) }; this.textarea = null; + this.editorInstance = null; } render() { const container = document.createElement('div'); container.style.width = '100%'; + container.className = 'ce-raw-html-wrapper mb-3'; const stretchWrapper = document.createElement('div'); stretchWrapper.className = 'custom-control custom-switch mb-2'; @@ -72,32 +121,73 @@ class SISRawHtmlTool { stretchWrapper.appendChild(this.stretchCheck); stretchWrapper.appendChild(stretchLabel); + container.appendChild(stretchWrapper); + // Header status bar + const headerBar = document.createElement('div'); + headerBar.className = 'bg-dark text-white p-2 rounded-top d-flex justify-content-between align-items-center small font-weight-bold'; + headerBar.innerHTML = ' Raw HTML Code (Monaco IDE Editor)HTML5'; + container.appendChild(headerBar); + + // Monaco Container + const editorContainer = document.createElement('div'); + editorContainer.className = 'monaco-editor-box border border-dark rounded-bottom'; + editorContainer.style.height = '350px'; + editorContainer.style.width = '100%'; + container.appendChild(editorContainer); + + // Textarea fallback (hidden when Monaco is active) this.textarea = document.createElement('textarea'); - this.textarea.className = 'form-control'; - this.textarea.style.fontFamily = 'monospace'; - this.textarea.style.fontSize = '13px'; - this.textarea.style.minHeight = '250px'; - this.textarea.style.whiteSpace = 'pre-wrap'; + this.textarea.className = 'form-control d-none'; this.textarea.value = this.data.html || ''; + container.appendChild(this.textarea); if (this.readOnly) { - this.textarea.disabled = true; this.stretchCheck.disabled = true; + this.textarea.disabled = true; } - this.textarea.addEventListener('input', () => { - this.data.html = this.textarea.value; + // Initialize Monaco Editor + window.loadMonacoEditor().then((monaco) => { + if (!editorContainer || !document.body.contains(editorContainer)) return; + this.editorInstance = monaco.editor.create(editorContainer, { + value: this.data.html || '', + language: 'html', + theme: 'vs-dark', + automaticLayout: true, + minimap: { enabled: false }, + fontSize: 13, + lineNumbers: 'on', + tabSize: 2, + scrollBeyondLastLine: false, + readOnly: !!this.readOnly + }); + + this.editorInstance.onDidChangeModelContent(() => { + this.data.html = this.editorInstance.getValue(); + if (this.textarea) this.textarea.value = this.data.html; + }); + }).catch((err) => { + console.warn('[SIS Raw HTML Tool] Monaco Editor CDN fallback to Textarea:', err); + this.textarea.classList.remove('d-none'); + this.textarea.style.minHeight = '300px'; + this.textarea.style.backgroundColor = '#1e1e24'; + this.textarea.style.color = '#66d9ef'; + this.textarea.style.fontFamily = 'monospace'; + editorContainer.style.display = 'none'; + + this.textarea.addEventListener('input', () => { + this.data.html = this.textarea.value; + }); }); - container.appendChild(stretchWrapper); - container.appendChild(this.textarea); return container; } - save(container) { + save() { + const htmlVal = this.editorInstance ? this.editorInstance.getValue() : (this.textarea ? this.textarea.value : (this.data.html || '')); return { - html: this.textarea ? this.textarea.value : this.data.html, + html: htmlVal, stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched) }; } diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/flex.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/flex.js index d53f95f5..0bac8ba1 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/flex.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/flex.js @@ -19,7 +19,9 @@ class FlexTool { direction: data.direction || 'row', justify: data.justify || 'start', align: data.align || 'stretch', - gap: data.gap || '3' // standard Bootstrap gap level + gap: data.gap || '3', // standard Bootstrap gap level + globalClass: data.globalClass || '', + globalId: data.globalId || '' }; this.activeInstances = {}; @@ -186,6 +188,7 @@ class FlexTool { classInput.value = this.data.globalClass || ''; if (this.readOnly) classInput.disabled = true; classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim()); + this.globalClassInput = classInput; classDiv.appendChild(classInput); bgSettingsRow.appendChild(classDiv); @@ -200,6 +203,7 @@ class FlexTool { idInput.value = this.data.globalId || ''; if (this.readOnly) idInput.disabled = true; idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim()); + this.globalIdInput = idInput; idDiv.appendChild(idInput); bgSettingsRow.appendChild(idDiv); @@ -710,8 +714,8 @@ class FlexTool { justify: this.data.justify, align: this.data.align, gap: this.data.gap, - globalClass: this.data.globalClass || '', - globalId: this.data.globalId || '' + globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || ''), + globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || '') }; for (let i = 1; i <= this.data.cols; i++) { const type = this.data[`type${i}`] || 'html'; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/grid.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/grid.js index aa7a326c..c8ef8d27 100644 --- a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/grid.js +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/grid.js @@ -15,7 +15,9 @@ class GridTool { this.api = api; this.readOnly = readOnly; this.data = { - cols: parseInt(data.cols) || 2 + cols: parseInt(data.cols) || 2, + globalClass: data.globalClass || '', + globalId: data.globalId || '' }; this.activeInstances = {}; @@ -80,6 +82,7 @@ class GridTool { classInput.value = this.data.globalClass || ''; if (this.readOnly) classInput.disabled = true; classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim()); + this.globalClassInput = classInput; classDiv.appendChild(classInput); settingsRow.appendChild(classDiv); @@ -94,6 +97,7 @@ class GridTool { idInput.value = this.data.globalId || ''; if (this.readOnly) idInput.disabled = true; idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim()); + this.globalIdInput = idInput; idDiv.appendChild(idInput); settingsRow.appendChild(idDiv); @@ -602,8 +606,8 @@ class GridTool { save(blockContent) { const savedData = { cols: this.data.cols, - globalClass: this.data.globalClass || '', - globalId: this.data.globalId || '' + globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || ''), + globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || '') }; for (let i = 1; i <= this.data.cols; i++) { const type = this.data[`type${i}`] || 'html'; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/hover-card.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/hover-card.js new file mode 100644 index 00000000..9d346592 --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/hover-card.js @@ -0,0 +1,428 @@ +/** + * SISHoverCardTool — Dedicated Hover Cards Block Tool for Editor.js + * Allows content managers to freely add, remove, reorder, and configure interactive hover cards. + */ +class SISHoverCardTool { + static get toolbox() { + return { + title: 'Hover Cards', + icon: '' + }; + } + + constructor({ data, api, readOnly }) { + this.api = api; + this.readOnly = readOnly; + this.instanceId = 'hc_' + Math.random().toString(36).substring(7); + this.data = { + cols: parseInt(data && data.cols) || 4, + styleVariant: (data && data.styleVariant) || 'v1', + globalClass: (data && data.globalClass) || 'sis-hover-card-section', + globalId: (data && data.globalId) || '', + items: (data && data.items && Array.isArray(data.items)) ? data.items : [] + }; + + // Add initial default item if list is empty + if (this.data.items.length === 0) { + this.data.items.push({ + title: 'Card Title', + description: 'Card description text...', + imageUrl: '', + active: true + }); + } + this.wrapper = undefined; + } + + render() { + this.wrapper = document.createElement('div'); + this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-hovercard-tool-wrapper'; + this.wrapper.style.fontFamily = 'inherit'; + + const titleLabel = document.createElement('div'); + titleLabel.className = 'font-weight-bold text-primary small mb-3'; + titleLabel.innerHTML = ' Dedicated Hover Cards Settings'; + this.wrapper.appendChild(titleLabel); + + // Global Settings Row + const settingsRow = document.createElement('div'); + settingsRow.className = 'form-row mb-3 pb-2 border-bottom'; + + // 1. Cols selector + const colDiv = document.createElement('div'); + colDiv.className = 'col-md-3 mb-2'; + colDiv.innerHTML = ''; + const colSelect = document.createElement('select'); + colSelect.className = 'form-control form-control-sm'; + [1, 2, 3, 4, 6].forEach(num => { + const opt = document.createElement('option'); + opt.value = num; + opt.textContent = `${num} Column${num > 1 ? 's' : ''}`; + if (num === this.data.cols) opt.selected = true; + colSelect.appendChild(opt); + }); + if (this.readOnly) colSelect.disabled = true; + colSelect.addEventListener('change', (e) => { + this.data.cols = parseInt(e.target.value) || 4; + }); + this.colsSelect = colSelect; + colDiv.appendChild(colSelect); + settingsRow.appendChild(colDiv); + + // 2. Style Variant Selector (Hover 1 vs Hover 2) + const variantDiv = document.createElement('div'); + variantDiv.className = 'col-md-3 mb-2'; + variantDiv.innerHTML = ''; + const variantSelect = document.createElement('select'); + variantSelect.className = 'form-control form-control-sm'; + + const optV1 = document.createElement('option'); + optV1.value = 'v1'; + optV1.textContent = 'Hover 1 (Standard Light)'; + if (this.data.styleVariant === 'v1') optV1.selected = true; + variantSelect.appendChild(optV1); + + const optV2 = document.createElement('option'); + optV2.value = 'v2'; + optV2.textContent = 'Hover 2 (Modern Blue & Glow)'; + if (this.data.styleVariant === 'v2') optV2.selected = true; + variantSelect.appendChild(optV2); + + if (this.readOnly) variantSelect.disabled = true; + variantSelect.addEventListener('change', (e) => { + this.data.styleVariant = e.target.value; + }); + this.variantSelect = variantSelect; + variantDiv.appendChild(variantSelect); + settingsRow.appendChild(variantDiv); + + // 2. Global CSS Class + const classDiv = document.createElement('div'); + classDiv.className = 'col-md-5 mb-2'; + classDiv.innerHTML = ''; + const classInput = document.createElement('input'); + classInput.type = 'text'; + classInput.className = 'form-control form-control-sm'; + classInput.placeholder = 'e.g. sis-hover-card-section'; + classInput.value = this.data.globalClass || ''; + if (this.readOnly) classInput.disabled = true; + classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim()); + this.globalClassInput = classInput; + classDiv.appendChild(classInput); + settingsRow.appendChild(classDiv); + + // 3. Global HTML ID + const idDiv = document.createElement('div'); + idDiv.className = 'col-md-4 mb-2'; + idDiv.innerHTML = ''; + const idInput = document.createElement('input'); + idInput.type = 'text'; + idInput.className = 'form-control form-control-sm'; + idInput.placeholder = 'e.g. hover-cards-section'; + idInput.value = this.data.globalId || ''; + if (this.readOnly) idInput.disabled = true; + idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim()); + this.globalIdInput = idInput; + idDiv.appendChild(idInput); + settingsRow.appendChild(idDiv); + + this.wrapper.appendChild(settingsRow); + + // Cards items container + this.itemsContainer = document.createElement('div'); + this.wrapper.appendChild(this.itemsContainer); + + this._renderAllItems(); + + if (!this.readOnly) { + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'btn btn-sm btn-success mt-2'; + addButton.innerHTML = ' Add Hover Card'; + addButton.addEventListener('click', () => { + this.data.items.push({ + title: 'New Card Title', + description: 'New card description text...', + imageUrl: '', + active: false + }); + this._renderAllItems(); + }); + this.wrapper.appendChild(addButton); + } + + return this.wrapper; + } + + _renderAllItems() { + this.itemsContainer.innerHTML = ''; + this.data.items.forEach((item, index) => { + this._renderItem(item, index); + }); + } + + _renderItem(item, index) { + const itemBox = document.createElement('div'); + itemBox.className = 'mb-3 p-3 bg-white border rounded shadow-sm'; + itemBox.style.position = 'relative'; + + // Top Control Action buttons (Move Up, Move Down, Delete) + if (!this.readOnly) { + const actionGroup = document.createElement('div'); + actionGroup.style.position = 'absolute'; + actionGroup.style.top = '10px'; + actionGroup.style.right = '10px'; + actionGroup.className = 'btn-group btn-group-sm'; + + if (index > 0) { + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'btn btn-outline-secondary'; + upBtn.innerHTML = ''; + upBtn.title = 'Move Up'; + upBtn.addEventListener('click', () => { + const temp = this.data.items[index]; + this.data.items[index] = this.data.items[index - 1]; + this.data.items[index - 1] = temp; + this._renderAllItems(); + }); + actionGroup.appendChild(upBtn); + } + + if (index < this.data.items.length - 1) { + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'btn btn-outline-secondary'; + downBtn.innerHTML = ''; + downBtn.title = 'Move Down'; + downBtn.addEventListener('click', () => { + const temp = this.data.items[index]; + this.data.items[index] = this.data.items[index + 1]; + this.data.items[index + 1] = temp; + this._renderAllItems(); + }); + actionGroup.appendChild(downBtn); + } + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'btn btn-danger'; + removeBtn.innerHTML = ''; + removeBtn.title = 'Remove Card'; + removeBtn.addEventListener('click', () => { + this.data.items.splice(index, 1); + this._renderAllItems(); + }); + actionGroup.appendChild(removeBtn); + + itemBox.appendChild(actionGroup); + } + + // Header Label + const itemHeader = document.createElement('h6'); + itemHeader.className = 'font-weight-bold text-dark mb-3'; + itemHeader.innerHTML = ` Card #${index + 1}`; + itemBox.appendChild(itemHeader); + + // 1. Card Title + const titleGroup = document.createElement('div'); + titleGroup.className = 'form-group mb-2'; + titleGroup.innerHTML = ''; + const titleInput = document.createElement('input'); + titleInput.type = 'text'; + titleInput.className = 'form-control form-control-sm'; + titleInput.placeholder = 'e.g. Tiên phong'; + titleInput.value = item.title || ''; + if (this.readOnly) titleInput.disabled = true; + titleInput.addEventListener('input', (e) => { item.title = e.target.value; }); + titleGroup.appendChild(titleInput); + itemBox.appendChild(titleGroup); + + // 2. Card Description + const descGroup = document.createElement('div'); + descGroup.className = 'form-group mb-2'; + descGroup.innerHTML = ''; + const descInput = document.createElement('textarea'); + descInput.className = 'form-control form-control-sm'; + descInput.rows = 2; + descInput.placeholder = 'e.g. Phát triển và ứng dụng kỹ thuật tiên tiến...'; + descInput.value = item.description || ''; + if (this.readOnly) descInput.disabled = true; + descInput.addEventListener('input', (e) => { item.description = e.target.value; }); + descGroup.appendChild(descInput); + itemBox.appendChild(descGroup); + + // 3. Image URL + Media Library Selector + const imgGroup = document.createElement('div'); + imgGroup.className = 'form-group mb-2'; + imgGroup.innerHTML = ''; + + const inputGroup = document.createElement('div'); + inputGroup.className = 'input-group input-group-sm mb-2'; + + const imgInput = document.createElement('input'); + imgInput.type = 'text'; + imgInput.className = 'form-control'; + imgInput.placeholder = '/uploads/2026/07/icon.jpg'; + imgInput.value = item.imageUrl || ''; + if (this.readOnly) imgInput.disabled = true; + + const previewContainer = document.createElement('div'); + previewContainer.className = 'mt-1'; + const updatePreview = (url) => { + previewContainer.innerHTML = ''; + if (url) { + const img = document.createElement('img'); + img.src = url; + img.style.maxHeight = '60px'; + img.style.maxWidth = '100px'; + img.style.objectFit = 'contain'; + img.className = 'border rounded p-1 bg-light'; + previewContainer.appendChild(img); + } + }; + updatePreview(item.imageUrl); + + imgInput.addEventListener('input', (e) => { + item.imageUrl = e.target.value.trim(); + updatePreview(item.imageUrl); + }); + + inputGroup.appendChild(imgInput); + + if (!this.readOnly) { + const appendDiv = document.createElement('div'); + appendDiv.className = 'input-group-append'; + + const mediaBtn = document.createElement('button'); + mediaBtn.type = 'button'; + mediaBtn.className = 'btn btn-outline-info'; + mediaBtn.innerHTML = ' Media Library'; + mediaBtn.addEventListener('click', () => { + if (typeof window.openSISMediaModal === 'function') { + window.openSISMediaModal((selectedUrl) => { + imgInput.value = selectedUrl; + item.imageUrl = selectedUrl; + updatePreview(selectedUrl); + }); + } else { + alert('Media modal function openSISMediaModal is not available.'); + } + }); + appendDiv.appendChild(mediaBtn); + inputGroup.appendChild(appendDiv); + } + + imgGroup.appendChild(inputGroup); + imgGroup.appendChild(previewContainer); + itemBox.appendChild(imgGroup); + + // 4. Hover Icon / Image URL (Swapped on hover) + const hoverImgGroup = document.createElement('div'); + hoverImgGroup.className = 'form-group mb-2'; + hoverImgGroup.innerHTML = ''; + + const hoverInputGroup = document.createElement('div'); + hoverInputGroup.className = 'input-group input-group-sm mb-2'; + + const hoverImgInput = document.createElement('input'); + hoverImgInput.type = 'text'; + hoverImgInput.className = 'form-control'; + hoverImgInput.placeholder = '/uploads/2026/07/hover-icon.jpg (optional)'; + hoverImgInput.value = item.hoverImageUrl || ''; + if (this.readOnly) hoverImgInput.disabled = true; + + const hoverPreviewContainer = document.createElement('div'); + hoverPreviewContainer.className = 'mt-1'; + const updateHoverPreview = (url) => { + hoverPreviewContainer.innerHTML = ''; + if (url) { + const img = document.createElement('img'); + img.src = url; + img.style.maxHeight = '60px'; + img.style.maxWidth = '100px'; + img.style.objectFit = 'contain'; + img.className = 'border rounded p-1 bg-info text-white'; + hoverPreviewContainer.appendChild(img); + } + }; + updateHoverPreview(item.hoverImageUrl); + + hoverImgInput.addEventListener('input', (e) => { + item.hoverImageUrl = e.target.value.trim(); + updateHoverPreview(item.hoverImageUrl); + }); + + hoverInputGroup.appendChild(hoverImgInput); + + if (!this.readOnly) { + const appendDiv = document.createElement('div'); + appendDiv.className = 'input-group-append'; + + const mediaBtn = document.createElement('button'); + mediaBtn.type = 'button'; + mediaBtn.className = 'btn btn-outline-info'; + mediaBtn.innerHTML = ' Media Library'; + mediaBtn.addEventListener('click', () => { + if (typeof window.openSISMediaModal === 'function') { + window.openSISMediaModal((selectedUrl) => { + hoverImgInput.value = selectedUrl; + item.hoverImageUrl = selectedUrl; + updateHoverPreview(selectedUrl); + }); + } else { + alert('Media modal function openSISMediaModal is not available.'); + } + }); + appendDiv.appendChild(mediaBtn); + hoverInputGroup.appendChild(appendDiv); + } + + hoverImgGroup.appendChild(hoverInputGroup); + hoverImgGroup.appendChild(hoverPreviewContainer); + itemBox.appendChild(hoverImgGroup); + + // Default Active Radio (Only 1 option active at a time) + const activeRadioDiv = document.createElement('div'); + activeRadioDiv.className = 'custom-control custom-radio mt-2'; + const activeRadio = document.createElement('input'); + activeRadio.type = 'radio'; + activeRadio.name = 'hovercard_active_' + this.instanceId; + activeRadio.className = 'custom-control-input'; + const radioId = 'hovercard_active_' + index + '_' + Math.random().toString(36).substring(7); + activeRadio.id = radioId; + activeRadio.checked = !!item.active; + if (this.readOnly) activeRadio.disabled = true; + + activeRadio.addEventListener('change', () => { + this.data.items.forEach((it, i) => { + it.active = (i === index); + }); + }); + + const activeLabel = document.createElement('label'); + activeLabel.className = 'custom-control-label small font-weight-bold text-secondary'; + activeLabel.htmlFor = radioId; + activeLabel.innerHTML = 'Default Active Card (Only 1 active at a time)'; + + activeRadioDiv.appendChild(activeRadio); + activeRadioDiv.appendChild(activeLabel); + itemBox.appendChild(activeRadioDiv); + + this.itemsContainer.appendChild(itemBox); + } + + save() { + return { + cols: this.colsSelect ? parseInt(this.colsSelect.value) : (this.data.cols || 4), + styleVariant: this.variantSelect ? this.variantSelect.value : (this.data.styleVariant || 'v1'), + globalClass: this.globalClassInput ? this.globalClassInput.value.trim() : (this.data.globalClass || 'sis-hover-card-section'), + globalId: this.globalIdInput ? this.globalIdInput.value.trim() : (this.data.globalId || ''), + items: this.data.items || [] + }; + } +} + +// Register the plugin globally +window.SISEditorPlugins = window.SISEditorPlugins || {}; +window.SISEditorPlugins['hovercard'] = { class: SISHoverCardTool }; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tag.js b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tag.js new file mode 100644 index 00000000..c5021c3d --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/editor-plugins/tag.js @@ -0,0 +1,201 @@ +/** + * SISTagTool — Custom HTML Tag Opening, Closing & Wrapper Block Tool for Editor.js + * Allows content managers to define custom HTML tags (div, section, article, header, footer, etc.) + * with custom Class(es), ID, and Attributes (data-*, style, aria-*, etc.). + */ +class SISTagTool { + static get toolbox() { + return { + title: 'HTML Tag (Open/Close)', + icon: '' + }; + } + + constructor({ data, api, readOnly }) { + this.api = api; + this.readOnly = readOnly; + this.data = { + mode: data && data.mode ? data.mode : 'open', // 'open' or 'close' + tagName: data && data.tagName ? data.tagName : 'div', + elementId: data && data.elementId ? data.elementId : '', + elementClass: data && data.elementClass ? data.elementClass : '', + attributes: data && data.attributes ? data.attributes : '' + }; + this.wrapper = undefined; + } + + render() { + this.wrapper = document.createElement('div'); + this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-tag-tool-wrapper'; + this.wrapper.style.fontFamily = 'inherit'; + this.wrapper.style.borderLeft = '4px solid #007bff'; + + const headerDiv = document.createElement('div'); + headerDiv.className = 'font-weight-bold text-primary small mb-3 d-flex justify-content-between align-items-center'; + headerDiv.innerHTML = ' HTML Custom Tag Settings<div>'; + this.wrapper.appendChild(headerDiv); + + const formRow = document.createElement('div'); + formRow.className = 'form-row'; + + // 1. Tag Mode (Opening Tag / Closing Tag) + const modeDiv = document.createElement('div'); + modeDiv.className = 'col-md-3 mb-2'; + modeDiv.innerHTML = ''; + const modeSelect = document.createElement('select'); + modeSelect.className = 'form-control form-control-sm'; + + const optOpen = document.createElement('option'); + optOpen.value = 'open'; + optOpen.textContent = 'Opening Tag ()'; + if (this.data.mode === 'open') optOpen.selected = true; + modeSelect.appendChild(optOpen); + + const optClose = document.createElement('option'); + optClose.value = 'close'; + optClose.textContent = 'Closing Tag ()'; + if (this.data.mode === 'close') optClose.selected = true; + modeSelect.appendChild(optClose); + + if (this.readOnly) modeSelect.disabled = true; + this.modeSelect = modeSelect; + modeDiv.appendChild(modeSelect); + formRow.appendChild(modeDiv); + + // 2. Tag Name (div, section, article, header, footer, etc.) + const nameDiv = document.createElement('div'); + nameDiv.className = 'col-md-3 mb-2'; + nameDiv.innerHTML = ''; + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.className = 'form-control form-control-sm'; + nameInput.placeholder = 'e.g. div, section, article'; + nameInput.value = this.data.tagName || 'div'; + if (this.readOnly) nameInput.disabled = true; + this.nameInput = nameInput; + nameDiv.appendChild(nameInput); + formRow.appendChild(nameDiv); + + // 3. Element ID + const idDiv = document.createElement('div'); + idDiv.className = 'col-md-3 mb-2'; + idDiv.innerHTML = ''; + const idInput = document.createElement('input'); + idInput.type = 'text'; + idInput.className = 'form-control form-control-sm'; + idInput.placeholder = 'e.g. heroSection'; + idInput.value = this.data.elementId || ''; + if (this.readOnly) idInput.disabled = true; + this.idInput = idInput; + idDiv.appendChild(idInput); + formRow.appendChild(idDiv); + + // 4. Element CSS Class(es) + const classDiv = document.createElement('div'); + classDiv.className = 'col-md-3 mb-2'; + classDiv.innerHTML = ''; + const classInput = document.createElement('input'); + classInput.type = 'text'; + classInput.className = 'form-control form-control-sm'; + classInput.placeholder = 'e.g. container py-5 my-section'; + classInput.value = this.data.elementClass || ''; + if (this.readOnly) classInput.disabled = true; + this.classInput = classInput; + classDiv.appendChild(classInput); + formRow.appendChild(classDiv); + + // 5. Custom Attributes Row + const attrRow = document.createElement('div'); + attrRow.className = 'form-row mt-2 attr-row'; + const attrDiv = document.createElement('div'); + attrDiv.className = 'col-12'; + attrDiv.innerHTML = ''; + const attrInput = document.createElement('input'); + attrInput.type = 'text'; + attrInput.className = 'form-control form-control-sm'; + attrInput.placeholder = 'e.g. data-aos="fade-up" style="background: #f8f9fa;" aria-label="Section"'; + attrInput.value = this.data.attributes || ''; + if (this.readOnly) attrInput.disabled = true; + this.attrInput = attrInput; + attrDiv.appendChild(attrInput); + attrRow.appendChild(attrDiv); + + // Live HTML Code Preview Box + const previewBox = document.createElement('div'); + previewBox.className = 'mt-3 p-2 bg-dark text-success rounded small font-weight-bold text-monospace live-tag-preview'; + previewBox.style.fontSize = '12px'; + + const updatePreview = () => { + const mode = this.modeSelect.value; + const tag = (this.nameInput.value.trim() || 'div').toLowerCase(); + const idVal = this.idInput.value.trim(); + const classVal = this.classInput.value.trim(); + const attrVal = this.attrInput.value.trim(); + + if (mode === 'close') { + previewBox.textContent = ``; + attrRow.style.display = 'none'; + idDiv.style.display = 'none'; + classDiv.style.display = 'none'; + } else { + attrRow.style.display = 'flex'; + idDiv.style.display = 'block'; + classDiv.style.display = 'block'; + let tagStr = `<${tag}`; + if (idVal) tagStr += ` id="${idVal}"`; + if (classVal) tagStr += ` class="${classVal}"`; + if (attrVal) tagStr += ` ${attrVal}`; + tagStr += `>`; + previewBox.textContent = tagStr; + } + + const previewBadge = this.wrapper.querySelector('.tag-preview-badge'); + if (previewBadge) previewBadge.textContent = previewBox.textContent; + }; + + this.modeSelect.addEventListener('change', updatePreview); + this.nameInput.addEventListener('input', updatePreview); + this.idInput.addEventListener('input', updatePreview); + this.classInput.addEventListener('input', updatePreview); + this.attrInput.addEventListener('input', updatePreview); + + this.wrapper.appendChild(formRow); + this.wrapper.appendChild(attrRow); + this.wrapper.appendChild(previewBox); + + updatePreview(); + return this.wrapper; + } + + save() { + const mode = this.modeSelect ? this.modeSelect.value : (this.data.mode || 'open'); + const tagName = this.nameInput ? (this.nameInput.value.trim().toLowerCase() || 'div') : (this.data.tagName || 'div'); + const elementId = this.idInput ? this.idInput.value.trim() : (this.data.elementId || ''); + const elementClass = this.classInput ? this.classInput.value.trim() : (this.data.elementClass || ''); + const attributes = this.attrInput ? this.attrInput.value.trim() : (this.data.attributes || ''); + + let rawHtmlTag = ''; + if (mode === 'close') { + rawHtmlTag = ``; + } else { + rawHtmlTag = `<${tagName}`; + if (elementId) rawHtmlTag += ` id="${elementId}"`; + if (elementClass) rawHtmlTag += ` class="${elementClass}"`; + if (attributes) rawHtmlTag += ` ${attributes}`; + rawHtmlTag += `>`; + } + + return { + mode: mode, + tagName: tagName, + elementId: elementId, + elementClass: elementClass, + attributes: attributes, + rawHtmlTag: rawHtmlTag + }; + } +} + +// Register the plugin globally +window.SISEditorPlugins = window.SISEditorPlugins || {}; +window.SISEditorPlugins['tag'] = { class: SISTagTool }; diff --git a/sisvietnamvn_main/src/main/resources/static/js/manage/sis-media-picker.js b/sisvietnamvn_main/src/main/resources/static/js/manage/sis-media-picker.js new file mode 100644 index 00000000..4a343869 --- /dev/null +++ b/sisvietnamvn_main/src/main/resources/static/js/manage/sis-media-picker.js @@ -0,0 +1,208 @@ +/** + * SIS Media Picker & Library Modal Utility + * Standalone reusable module to select and upload media files anywhere in the application. + * + * Usage: + * window.openSISMediaModal(function(selectedUrl, selectedMediaObj) { + * console.log("Selected Image URL:", selectedUrl); + * }); + * + * Or via object API: + * SISMediaPicker.open(function(selectedUrl, selectedMediaObj) { ... }); + */ +(function (window, document) { + 'use strict'; + + var SISMediaPicker = { + callback: null, + mediaData: [], + modalId: 'sisMediaLibraryModal', + + initModal: function () { + var modalEl = document.getElementById(this.modalId); + if (modalEl) return modalEl; + + modalEl = document.createElement('div'); + modalEl.id = this.modalId; + modalEl.className = 'modal fade'; + modalEl.setAttribute('tabindex', '-1'); + modalEl.setAttribute('role', 'dialog'); + modalEl.setAttribute('aria-hidden', 'true'); + modalEl.innerHTML = + ''; + + document.body.appendChild(modalEl); + + // Bind Search Input Handler + var self = this; + document.addEventListener('input', function (e) { + if (e.target && e.target.id === 'sisMediaSearchInput') { + var term = e.target.value.toLowerCase().trim(); + var filtered = self.mediaData.filter(function (item) { + var name = (item.originalFilename || item.name || '').toLowerCase(); + return name.includes(term); + }); + self.renderGrid(filtered); + } + }); + + // Bind Upload Input Handler + document.addEventListener('change', function (e) { + if (e.target && e.target.id === 'sisMediaUploadInput') { + var file = e.target.files[0]; + if (!file) return; + + var formData = new FormData(); + formData.append('file', file); + + var grid = document.getElementById('sisMediaGrid'); + if (grid) { + grid.innerHTML = '

Đang tải tệp lên server...
'; + } + + fetch('/api/manage/media/upload', { + method: 'POST', + body: formData + }) + .then(function (res) { return res.json(); }) + .then(function (data) { + if (data && data.success === 1 && data.file && data.file.url) { + self.selectItem(data.file.url, data.file); + } else { + alert('Tải tệp lên thất bại. Vui lòng thử lại!'); + self.fetchMediaList(); + } + }) + .catch(function (err) { + alert('Lỗi tải tệp: ' + err.message); + self.fetchMediaList(); + }); + } + }); + + return modalEl; + }, + + open: function (callback) { + this.callback = callback; + var modalEl = this.initModal(); + + if (typeof $ !== 'undefined' && $.fn && $.fn.modal) { + $(modalEl).modal('show'); + } else { + modalEl.style.display = 'block'; + modalEl.classList.add('show'); + } + this.fetchMediaList(); + }, + + fetchMediaList: function () { + var grid = document.getElementById('sisMediaGrid'); + if (grid) { + grid.innerHTML = '

Đang nạp thư viện...
'; + } + + var self = this; + fetch('/api/manage/media/list') + .then(function (res) { return res.json(); }) + .then(function (data) { + self.mediaData = data || []; + self.renderGrid(self.mediaData); + }) + .catch(function (err) { + if (grid) { + grid.innerHTML = '

Không thể tải danh sách tệp. Bạn có thể dán đường dẫn URL trực tiếp.
'; + } + }); + }, + + renderGrid: function (list) { + var grid = document.getElementById('sisMediaGrid'); + if (!grid) return; + + if (!list || list.length === 0) { + grid.innerHTML = '

Chưa có tệp hình ảnh nào. Bấm nút "Tải ảnh mới lên" để thêm.
'; + return; + } + + var self = this; + grid.innerHTML = list.map(function (item) { + var url = item.fileUrl || item.url || ''; + var name = item.originalFilename || item.name || 'Image'; + return '
' + + '
' + + '
' + + '' + name + '' + + '
' + + '
' + + '

' + name + '

' + + '
' + + '
' + + '
'; + }).join(''); + + // Add click listeners to items + grid.querySelectorAll('.media-select-card').forEach(function (card) { + card.addEventListener('click', function () { + var selectedUrl = card.getAttribute('data-url'); + self.selectItem(selectedUrl); + }); + }); + }, + + selectItem: function (url, mediaObj) { + if (typeof this.callback === 'function') { + this.callback(url, mediaObj); + } + var modalEl = document.getElementById(this.modalId); + if (modalEl) { + if (typeof $ !== 'undefined' && $.fn && $.fn.modal) { + $(modalEl).modal('hide'); + } else { + modalEl.style.display = 'none'; + modalEl.classList.remove('show'); + } + } + } + }; + + // Expose standalone module globally + window.SISMediaPicker = SISMediaPicker; + + // Backward-compatible global helper function + window.openSISMediaModal = function (callback) { + SISMediaPicker.open(callback); + }; + +})(window, document); diff --git a/sisvietnamvn_main/src/main/resources/templates/manage/pages/form.html b/sisvietnamvn_main/src/main/resources/templates/manage/pages/form.html index 8ebffc6a..9c643f0c 100644 --- a/sisvietnamvn_main/src/main/resources/templates/manage/pages/form.html +++ b/sisvietnamvn_main/src/main/resources/templates/manage/pages/form.html @@ -225,7 +225,12 @@ - CSS3 +
+ + CSS3 +